whl&cUphfZvPnfgwWYZDP7Aa~>+XPZ*(>KN?@ds{izIJ_GREAg@rVg)Y=PdvB
zxU0iGBlF&$n0v4PS)e{7LxK5tC@66Z|1Qw~cU1aOe7@!ST-b4x2HR
z{K{uJnWthOR(=cK9^q*kNly|H^V(<3ps$OXK1xw>R4liQDNDJ1bc1vC4u;~wH2;>z
z@i>?(fetGG!>_mf0ssRpz0aTMk6h^J5*1=YF6M&q>V=
z2yiI0l0L*C-UNooV>q#L5Y*Lhp)GP6=DAnCo051NO%VjYN1$ljVyZ)nOw1
z0E6cN)eMwYL3S^gg83#HhFpY&8;f)*@q_9NQ`qJxKBjbDbaWUs%7mDr^L3p6>bbbm
zAeS?W_;AA+RuN#hwo1$ybuUzW*Q{H$ne~dL4id
zJypo?+r$&yY%$iTKrxg(y<6uAU&QYHsl|dHaP2(q#{l1sJ&<
z{eJGn0Y#OpPblnRHp&a*1sz}Tg2x%<-GCV885yg{vXovIRSVS%
z4;RqU&Ka46@dC=h{_w5Ma(}$uv(Fq+l0t%;$uZRmQXknJ;D<`?>3rJYAh@eWmlqX$
zqe)6)jkDPi0blrz4Lp~3qhW;Mv$ReGDrF^YA=4Q80;;mfVeJkVuRwA<9g;sR_gMZ|w(0MmFZBQJQJuC@J1676SD@Pm~(k*2Sz-)RW&UoC46
zCO;R6M6|qWqJ~y{5F5bvb>bbbZ;=p2K{_V*s7y=0#&B>TXX>H&D0lqhqenJ~{Kdk1
z(Gt+*g}l3B4m0!)P*-1REORiPPAXrIWo=z~<{Kxu*J1aF
zv+cXElajU#rUSl9q6dzn?m19ZWM7=xO2-1z|685F4I@`OAR*5Io^wow|lXMG#U
z1biINvsa0>7(ha+GoOB4v$s&}n2TorDM!H}e>6F+%?_dy&}FtusnjL{I;>tM@@O)aacU*dzacclE9F`&Y=(0lRLUxa?UTIqab=E*
zN7km&Qz)Sm)Di>(_%28uz*=X1R)&?m<>mtefCbvvWl^%w6I>3bA}hN3Z;w^%?F9DO
zxY0rAcP1iL$R0M>1Uge4tq>~*41r$nM&bCGQ%s0XzJ%-(Jpf-Vbfo5(*oZ5KZ5uDu
zY+L!K2#3a7?BO7{Bn*hc1~{1Rj#QH<{%6ae%Q<3VLl=_oU=(@zP^K8!$DV?2NTD1n
z@sycicg_sxJ`>viERsXwn3;QNCewUqxm@?o%~>F1aw{$_IATnCA~WFAtH7qIh76L#
zk%6N0c#}%ZKIG7{2tPpQlOTJZ)`+5Es+C5AZ-AlQz8S-ik*E-8iU2>t!-_fMIE$xr
z+GW7>Rh_6Yci;1$it7S?Yu?%~(%NY3G>JbuXnI)seNRceQd?BiQq4jqveJ0+(gL6J~2x!hzu3
zrv)Pbv7~C%{4H(<%B398Gd#7NYLFeTuNbKJ7}6(I{o~f0=j8#RP`
z-R0t{keAB`el=92E-me724rM^x_w``5ig!G&Pcpz+|t0De8(}8eAT}{rG-8HyUd8*
z6h_3+Kz>x*uVJ(%b&-(bR4u;O?N2Ji@;)>s;g`x{)6FI+rSBJ#yfO^UN?0!g^qOFl
zfp%&+J@OB>d8+Lt;aiDdw+JVc_Ol5zQnE_Jd__i0r#Gn3TemvViZ?)v4PS8YM@%g*
ze1Q?OpqMnaMKJ=`U74qh0An9oOB8A#>8sW>Xj1RW!}-R$UE1-
zUXyn4IB+eIJe}AP|0+4O#x87CkgmCA%9c
zPaSm5b!f^GRw^;HmiVNbpd4&gY*;K&)!RHmS#p+0(av=%;&_2^M|pE<+mU2NPW9@!
zh}V+{DKv*|w6CIYCf(=i1_7Y_{h9FiS1elj#o2kWvx@5wNI08xg3}{fQO25H2uX#K
z0&5TD&YM{S4L@&!6|qH+c*!$+JaO^cF9f7>Nhx?r?gSSzm^8PaO+_1+2Y#$krYAT)
z?WY@AMb5Q~usrm}v94^8ZpVb0jm}A^Qy3k+JT>H4ft;uBzkYWvfw#uxvVJzMPMRDn
zwi5RfX
zar>bylFKhvIq7~mmlRS(%dUDY`K;c2wbNc5J=_AYj_?F#hW8!eH9_8mcOSGc1n_s6
z4ch}4R*F?8&Q5B+^R;WV;+IU8L}MM&bO)k5C8N7#a6gQiT{m4_+}^zM+VE}p%JDNg
zI$=eR^d*jIBEbcQNd?<;)%ejJDxSoB-;T|bJSeQnWXNSb#@tA7I;hEnM|{sO
zR&=s|HT3?Gj?Y@9ZD^9~(=vUTq`|!H&SYIy?Q*@nGSwNQa=j^YuIcbqP|4RPj*peS
zQwfdiRlnrthY!^8HJV7$ZNOdRKBu|Qqm$1L)$7&n5fZ3iup43+i!ZNEnHfl1hs898
z1U=Kwf;Z@NOAdUh&FM507bR0(cu>2jY|(|ii2jpxFk|4PE3z7>OYNV$jYeh^bmZlC
z$w*pRBlXRAVh2ld5dDQ%#af|f<>5%ZtVq07l_b!fdEI2HfX!2N#5EN_t(~uAijqU=`H;ydGOFBU>I3
zUho{*a4dySDuNSJ?Ga*EK?nTu0V1z?oBOCT2-Z
zAjAS*e7ArcpCg|Yc_a4bwaG!i@%wseoP1gD1
zl?=Gu%9P%SUcPGV-2h{lFV(lP({Osa!eE{-7#gm+euT~W#Jr874#XJMRGnSJiWWhs
zB(+_(=m&=6rS#5r|H}3;hdiRSqE^OBl@?ZwAABjbN@#r*@MU&uR#u~tltlz5h;2Zn~ABF?SNljVTR{HsA}pEozbcwv}RGMDdcKva1qK6?O(
zQc%db9JJ9HO#1vL2&f+{#ffbbJTVcK_OR#h^rTul+{zm=bSOenwRP05xk&ZCO|Ec|
zJcG~v&`pcP6o=4TPHRs>uY-k{=|NB1S*iaWCe5Ov2?fMwt!k%asBy{Jf&s7$V-TMc;*v%*CIo#Xjk7B
z@rYodl5_@7{u@m%jn0*E)Sye{W&voYnP5QaLy!A*o1FE^(gBHqJL>ay4IE)qezZ^L
z9-=(oO?CN6^7Q%q6S-f4o_bb`;cFh2Tz{YWpH*xs=T{HdNHgdx
zJTbr+NS&2;D&7e4ls_>z6?S)hzBt+ZR^L?{CbYw@5S=^~T-nlhVzLQ(tIJN@R^j@i
z+t0FqZmW!`cmQ+6;M~fA^T>Hw{FI_G>BZ$(AU^iAqB*|;nz-!E5m)`&P}_OMCuCsO
zR3L?(I2Q(DllFwyS6n(Y8!YAo4DfTKleC`c?5o5vq`La7bu^X(RgoZ{H0>m5WbU%A
z7#p(i)(2R~&$VN#B73Z}`Q8M%R+%b-&(`!uK5Dns&W%zsKq($mNf|HlEenV(giw``
zRx~`Qp(?pjpB*2UA!1%mbbn<=_#&pYec^Ns{}+0~D-}xIlJ9tmlunV4LC#^-z1^&On*G4&8B?U$l9p+;D?<<*q4}s
z1tx16q-Rs+YFfIVt>TBOYr6`?R)!tlwaw47xKC=gKHf%^a4J!#wl5p|Q!Sj;?p;c?
z8gaV!G3hwzbH2UHzNDFJCCIMr#|j&)+YAe`UuX6Q{3Z-IyyL$_@7c5&<{!fFFBJA)
zxyby_T=a5;nR#0MI~QRNs{a?U{0pi~>#0B&g$diP^eR>B>o&p%e<0<74Lhg0$2!NF
z+E}c%kg8->E+rP+t#Dm!RvDAY2or#$R$P=SlOOxJ5_>P>eGOOCgb75cPfCDvQ9v1Mev6Z&KJgoN0b#~!?^SzPROs)O~Hoagp>jA_67$HaSG;2
z?%aQ$+A2F5_e|qv}Zzxc_E4x!E%DnG{Ci(*dF}x@L8D>KFcT8d2(t6zL>LFTRYf
zc4zSQ55%rV+=NB@8*uI0+pR!3Tkjy(PX05C_o5~`(UDO%{JH(aw3{Rfp9K`jG^(fv
z%_HG(I{uZPs1V-JsF^P=iZACEXORrd-1!L6Mgxm#yU3(*fD((TZe!TqdvRV6IkXFH-
zA#s04*19;hw?bci988eI?y?!kuTf0-BN119rLGlFb*SiRnJwwvdz|kHHa^IG5t-`Avx{R1QBiE*dLXK9(b@Dnr7rZlBp~G3?0h$|f{8rWZ9W
zr#b3{)k4o*wEau*=JcC;jEIR9E=fU=eGgGX34a$o09ES2|2(X^ul#>mZQ$?MUzqV9
zqpSbc`0we#zZFnWY6ENTTh0Gxmhea54~G9c>HUr2|5#pN>HW6<+QdJg{C{iwot^$>
z!hb9;&>a2m8vhF+{v-KkkoVuv^^at2pw9hh??2o63&H*o`0dy4^ZoDL_V@Yz$MOQ%
z?<4RZfj?*a|9jg1UGeN*@$aGh-KGCnUSR$GkNcbA|I)KHRnYIZkAi}E|2()4P$}i_
G)&BuGn@C*%
diff --git a/editor/dve/language-configuration.json b/editor/dve/language-configuration.json
deleted file mode 100644
index ae56a0e..0000000
--- a/editor/dve/language-configuration.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "comments": {},
- "brackets": [
- ["{{", "}}"],
- ["{{{", "}}}"]
- ],
- "autoClosingPairs": [
- { "open": "{{", "close": "}}" },
- { "open": "{{{", "close": "}}}" },
- { "open": "\"", "close": "\"", "notIn": ["string"] },
- { "open": "'", "close": "'", "notIn": ["string"] }
- ],
- "surroundingPairs": [
- ["{{", "}}"],
- ["{{{", "}}}"],
- ["\"", "\""],
- ["'", "'"]
- ]
-}
diff --git a/editor/dve/package.json b/editor/dve/package.json
deleted file mode 100644
index 1bcaa23..0000000
--- a/editor/dve/package.json
+++ /dev/null
@@ -1,45 +0,0 @@
-{
- "name": "dve-language",
- "displayName": "DVE Template Language",
- "description": "Syntax highlighting for Deserve (.dve) templates",
- "version": "0.1.0",
- "publisher": "neabyte",
- "engines": {
- "vscode": "^1.74.0"
- },
- "categories": [
- "Programming Languages"
- ],
- "contributes": {
- "languages": [
- {
- "id": "dve",
- "aliases": [
- "DVE",
- "dve"
- ],
- "extensions": [
- ".dve"
- ],
- "configuration": "./language-configuration.json"
- }
- ],
- "snippets": [
- {
- "language": "dve",
- "path": "./snippets/dve.code-snippets"
- }
- ],
- "grammars": [
- {
- "language": "dve",
- "scopeName": "text.html.dve",
- "path": "./syntaxes/dve.tmLanguage.json",
- "embeddedLanguages": {
- "meta.embedded.line.dve": "dve",
- "meta.embedded.block.dve": "dve"
- }
- }
- ]
- }
-}
diff --git a/editor/dve/snippets/dve.code-snippets b/editor/dve/snippets/dve.code-snippets
deleted file mode 100644
index 44899f3..0000000
--- a/editor/dve/snippets/dve.code-snippets
+++ /dev/null
@@ -1,61 +0,0 @@
-{
- "DVE: Tag": {
- "prefix": "dve",
- "body": ["{{ ${1:value} }}"],
- "description": "Insert DVE tag: {{ value }}"
- },
- "DVE: Raw Tag": {
- "prefix": "dveraw",
- "body": ["{{{ ${1:html} }}}"],
- "description": "Insert DVE raw tag: {{{ html }}}"
- },
- "DVE: Include": {
- "prefix": "dveinc",
- "body": ["{{> ${1:partials/header.dve} }}"],
- "description": "Insert DVE include: {{> path }}"
- },
- "DVE: If / Else": {
- "prefix": "dveif",
- "body": ["{{#if ${1:condition}}}${2:then}{{else}}${3:else}{{/if}}"],
- "description": "Insert DVE if/else block"
- },
- "DVE: If": {
- "prefix": "dveifn",
- "body": ["{{#if ${1:condition}}}", " ${2:then}", "{{/if}}"],
- "description": "Insert DVE if block (multi-line)"
- },
- "DVE: Each": {
- "prefix": "dveeach",
- "body": ["{{#each ${1:items} as ${2:item}}}", " ${3:{{ item }}}", "{{/each}}"],
- "description": "Insert DVE each block"
- },
- "DVE: Each (With Meta)": {
- "prefix": "dveeachm",
- "body": [
- "{{#each ${1:items} as ${2:item}}}",
- " ({{ @index }}/{{ @length }}) ${3:{{ item }}}",
- "{{/each}}"
- ],
- "description": "Insert DVE each block with @index/@length"
- },
- "DVE: Ternary": {
- "prefix": "dvetern",
- "body": ["{{ ${1:condition} ? ${2:yes} : ${3:no} }}"],
- "description": "Insert DVE ternary: {{ cond ? yes : no }}"
- },
- "DVE: Default (Nullish)": {
- "prefix": "dvedef",
- "body": ["{{ ${1:value} ?? ${2:'fallback'} }}"],
- "description": "Insert DVE nullish default: {{ value ?? 'fallback' }}"
- },
- "DVE: Optional Chain": {
- "prefix": "dveopt",
- "body": ["{{ ${1:user}?.${2:name} ?? ${3:'Guest'} }}"],
- "description": "Insert DVE optional chain: {{ user?.name ?? 'Guest' }}"
- },
- "DVE: Comment (HTML)": {
- "prefix": "dvecmt",
- "body": [""],
- "description": "Insert HTML comment inside template"
- }
-}
diff --git a/editor/dve/syntaxes/dve.tmLanguage.json b/editor/dve/syntaxes/dve.tmLanguage.json
deleted file mode 100644
index ac7e8db..0000000
--- a/editor/dve/syntaxes/dve.tmLanguage.json
+++ /dev/null
@@ -1,141 +0,0 @@
-{
- "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
- "name": "DVE",
- "scopeName": "text.html.dve",
- "fileTypes": ["dve"],
- "patterns": [{ "include": "#dve-tags" }, { "include": "text.html.basic" }],
- "repository": {
- "dve-tags": {
- "patterns": [
- { "include": "#dve-raw-tag" },
- { "include": "#dve-include-tag" },
- { "include": "#dve-block-tag" },
- { "include": "#dve-output-tag" }
- ]
- },
- "dve-raw-tag": {
- "begin": "\\{\\{\\{",
- "beginCaptures": {
- "0": { "name": "punctuation.section.embedded.begin.dve" }
- },
- "end": "\\}\\}\\}",
- "endCaptures": {
- "0": { "name": "punctuation.section.embedded.end.dve" }
- },
- "name": "meta.embedded.line.dve meta.tag.raw.dve",
- "contentName": "meta.expression.dve",
- "patterns": [{ "include": "#expression" }]
- },
- "dve-include-tag": {
- "begin": "\\{\\{\\s*(>)",
- "beginCaptures": {
- "0": { "name": "punctuation.section.embedded.begin.dve" },
- "1": { "name": "keyword.control.include.dve" }
- },
- "end": "\\}\\}",
- "endCaptures": {
- "0": { "name": "punctuation.section.embedded.end.dve" }
- },
- "name": "meta.embedded.line.dve meta.tag.include.dve",
- "patterns": [{ "include": "#path" }]
- },
- "dve-block-tag": {
- "begin": "\\{\\{\\s*(#if|#each|else|/if|/each)\\b",
- "beginCaptures": {
- "0": { "name": "punctuation.section.embedded.begin.dve" },
- "1": { "name": "keyword.control.dve" }
- },
- "end": "\\}\\}",
- "endCaptures": {
- "0": { "name": "punctuation.section.embedded.end.dve" }
- },
- "name": "meta.embedded.block.dve meta.tag.block.dve",
- "patterns": [
- {
- "match": "\\b(as)\\s+([a-zA-Z_$][a-zA-Z0-9_$]*)",
- "captures": {
- "1": { "name": "keyword.operator.as.dve" },
- "2": { "name": "variable.parameter.dve" }
- }
- },
- { "include": "#expression" }
- ]
- },
- "dve-output-tag": {
- "begin": "\\{\\{",
- "beginCaptures": {
- "0": { "name": "punctuation.section.embedded.begin.dve" }
- },
- "end": "\\}\\}",
- "endCaptures": {
- "0": { "name": "punctuation.section.embedded.end.dve" }
- },
- "name": "meta.embedded.line.dve meta.tag.output.dve",
- "contentName": "meta.expression.dve",
- "patterns": [{ "include": "#expression" }]
- },
- "expression": {
- "patterns": [
- { "include": "#string-double" },
- { "include": "#string-single" },
- { "include": "#number" },
- { "include": "#literal" },
- { "include": "#operator" },
- { "include": "#identifier" }
- ]
- },
- "path": {
- "match": "[@a-zA-Z0-9_$./\\\\-]+\\.dve|[@a-zA-Z_$][a-zA-Z0-9_$.]*",
- "name": "string.unquoted.path.dve"
- },
- "string-double": {
- "begin": "\"",
- "end": "\"",
- "name": "string.quoted.double.dve",
- "patterns": [
- {
- "match": "\\\\.",
- "name": "constant.character.escape.dve"
- }
- ]
- },
- "string-single": {
- "begin": "'",
- "end": "'",
- "name": "string.quoted.single.dve",
- "patterns": [
- {
- "match": "\\\\.",
- "name": "constant.character.escape.dve"
- }
- ]
- },
- "number": {
- "match": "\\b[0-9]+(\\.[0-9]+)?([eE][+-]?[0-9]+)?\\b",
- "name": "constant.numeric.dve"
- },
- "literal": {
- "match": "\\b(true|false|null|undefined)\\b",
- "name": "constant.language.dve"
- },
- "operator": {
- "match": "\\?\\.|===|!==|==|!=|&&|\\|\\||\\?\\?|>=|<=|[?.!:()+\\-*/%<>]",
- "name": "keyword.operator.dve"
- },
- "identifier": {
- "match": "\\b([a-zA-Z_$@][a-zA-Z0-9_$]*)\\b",
- "name": "variable.other.dve"
- }
- },
- "injections": {
- "L:text.html.dve - (meta.embedded.line.dve | meta.embedded.block.dve)": {
- "patterns": [{ "include": "#dve-tags" }]
- },
- "L:text.html.basic": {
- "patterns": [{ "include": "#dve-tags" }]
- },
- "L:string.quoted.double.html, L:string.quoted.single.html": {
- "patterns": [{ "include": "#dve-tags" }]
- }
- }
-}
From b736dd778f0f4ac088a396a2893d2829a2f83769 Mon Sep 17 00:00:00 2001
From: NeaByteLab <209737579+NeaByteLab@users.noreply.github.com>
Date: Thu, 25 Jun 2026 22:14:02 +0700
Subject: [PATCH 02/40] chore(deno): swap render/validation aliases for dve
dependency
- Add @neabyte/deserve self-alias to source barrel
- Add @neabyte/dve dependency for template rendering
- Remove deleted @rendering and @validation path aliases
---
deno.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/deno.json b/deno.json
index bf2d0c3..9a0ddec 100644
--- a/deno.json
+++ b/deno.json
@@ -62,18 +62,18 @@
"test": "deno test tests/ --allow-read --allow-net"
},
"imports": {
+ "@neabyte/dve": "jsr:@neabyte/dve@^0.1.0",
"@neabyte/fast-router": "jsr:@neabyte/fast-router@^0.1.0",
"@neabyte/superwatcher": "jsr:@neabyte/superwatcher@^0.1.1",
"@neabyte/typebox": "jsr:@neabyte/typebox@^0.1.1",
"@neabyte/utils-core": "jsr:@neabyte/utils-core@^0.2.0",
"@std/assert": "jsr:@std/assert@^1.0.19",
+ "@neabyte/deserve": "./src/index.ts",
"@app/": "./src/",
"@core/": "./src/core/",
"@interfaces/": "./src/interfaces/",
"@middleware/": "./src/middleware/",
- "@rendering/": "./src/rendering/",
"@routing/": "./src/routing/",
- "@validation/": "./src/validation/",
"@tests/": "./tests/"
},
"publish": {
From e45fce8270c149cb3a2df0a4a2d32f7fca1aa308 Mon Sep 17 00:00:00 2001
From: NeaByteLab <209737579+NeaByteLab@users.noreply.github.com>
Date: Thu, 25 Jun 2026 22:14:09 +0700
Subject: [PATCH 03/40] feat(core): add Cookie serialization module
- Add Cookie.serialize building validated Set-Cookie strings
- Reject empty name, invalid expires, and non-finite maxAge
- Require secure flag when SameSite is set to None
---
src/core/Cookie.ts | 59 ++++++++++++++++++++++++++++
tests/core/Cookie.test.ts | 82 +++++++++++++++++++++++++++++++++++++++
2 files changed, 141 insertions(+)
create mode 100644 src/core/Cookie.ts
create mode 100644 tests/core/Cookie.test.ts
diff --git a/src/core/Cookie.ts b/src/core/Cookie.ts
new file mode 100644
index 0000000..2d23af4
--- /dev/null
+++ b/src/core/Cookie.ts
@@ -0,0 +1,59 @@
+import type * as Types from '@interfaces/index.ts'
+
+/**
+ * Cookie header serialization helper.
+ * @description Builds Set-Cookie header strings from options.
+ */
+export class Cookie {
+ /**
+ * Serialize cookie into header string.
+ * @description Encodes name, value, and attribute options.
+ * @param name - Cookie name to set
+ * @param value - Cookie value to set
+ * @param options - Optional cookie attribute values
+ * @returns Serialized Set-Cookie header string
+ * @throws When name, expires, maxAge, or sameSite invalid
+ */
+ static serialize(name: string, value: string, options?: Types.CookieInit): string {
+ if (name.length === 0) {
+ throw new TypeError('Cookie name must be a non-empty string')
+ }
+ const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`]
+ if (options !== undefined) {
+ if (options.path !== undefined) {
+ parts.push(`Path=${options.path}`)
+ }
+ if (options.domain !== undefined) {
+ parts.push(`Domain=${options.domain}`)
+ }
+ if (options.expires !== undefined) {
+ const expires = options.expires instanceof Date
+ ? options.expires
+ : new Date(options.expires)
+ if (Number.isNaN(expires.getTime())) {
+ throw new TypeError('Cookie expires must be a valid Date or timestamp')
+ }
+ parts.push(`Expires=${expires.toUTCString()}`)
+ }
+ if (options.maxAge !== undefined) {
+ if (!Number.isFinite(options.maxAge)) {
+ throw new TypeError('Cookie maxAge must be a finite number of seconds')
+ }
+ parts.push(`Max-Age=${Math.trunc(options.maxAge)}`)
+ }
+ if (options.sameSite !== undefined) {
+ if (options.sameSite === 'None' && options.secure !== true) {
+ throw new TypeError('Cookie sameSite None requires secure true')
+ }
+ parts.push(`SameSite=${options.sameSite}`)
+ }
+ if (options.secure === true) {
+ parts.push('Secure')
+ }
+ if (options.httpOnly === true) {
+ parts.push('HttpOnly')
+ }
+ }
+ return parts.join('; ')
+ }
+}
diff --git a/tests/core/Cookie.test.ts b/tests/core/Cookie.test.ts
new file mode 100644
index 0000000..8e7d3f3
--- /dev/null
+++ b/tests/core/Cookie.test.ts
@@ -0,0 +1,82 @@
+import { assertEquals } from '@std/assert'
+import * as Core from '@core/index.ts'
+
+Deno.test('Cookie serialize allows SameSite None with secure', () => {
+ const value = Core.Cookie.serialize('sid', 'abc', { sameSite: 'None', secure: true })
+ assertEquals(value.includes('SameSite=None'), true)
+ assertEquals(value.includes('Secure'), true)
+})
+
+Deno.test('Cookie serialize appends expires from Date', () => {
+ const value = Core.Cookie.serialize('sid', 'abc', { expires: new Date(0) })
+ assertEquals(value.includes('Expires='), true)
+})
+
+Deno.test('Cookie serialize appends maxAge and flags', () => {
+ const value = Core.Cookie.serialize('sid', 'abc', {
+ maxAge: 60,
+ secure: true,
+ httpOnly: true
+ })
+ assertEquals(value.includes('Max-Age=60'), true)
+ assertEquals(value.includes('Secure'), true)
+ assertEquals(value.includes('HttpOnly'), true)
+})
+
+Deno.test('Cookie serialize appends path and domain', () => {
+ const value = Core.Cookie.serialize('sid', 'abc', { path: '/', domain: 'example.com' })
+ assertEquals(value.includes('Path=/'), true)
+ assertEquals(value.includes('Domain=example.com'), true)
+})
+
+Deno.test('Cookie serialize builds basic name value pair', () => {
+ assertEquals(Core.Cookie.serialize('sid', 'abc'), 'sid=abc')
+})
+
+Deno.test('Cookie serialize throws on SameSite None without secure', () => {
+ let threw = false
+ try {
+ Core.Cookie.serialize('sid', 'abc', { sameSite: 'None' })
+ } catch (e) {
+ threw = true
+ assertEquals(e instanceof TypeError, true)
+ }
+ assertEquals(threw, true)
+})
+
+Deno.test('Cookie serialize throws on empty name', () => {
+ let threw = false
+ try {
+ Core.Cookie.serialize('', 'abc')
+ } catch (e) {
+ threw = true
+ assertEquals(e instanceof TypeError, true)
+ }
+ assertEquals(threw, true)
+})
+
+Deno.test('Cookie serialize throws on invalid expires', () => {
+ let threw = false
+ try {
+ Core.Cookie.serialize('sid', 'abc', { expires: new Date('invalid') })
+ } catch (e) {
+ threw = true
+ assertEquals(e instanceof TypeError, true)
+ }
+ assertEquals(threw, true)
+})
+
+Deno.test('Cookie serialize throws on non-finite maxAge', () => {
+ let threw = false
+ try {
+ Core.Cookie.serialize('sid', 'abc', { maxAge: Infinity })
+ } catch (e) {
+ threw = true
+ assertEquals(e instanceof TypeError, true)
+ }
+ assertEquals(threw, true)
+})
+
+Deno.test('Cookie serialize url-encodes name and value', () => {
+ assertEquals(Core.Cookie.serialize('a b', 'c d'), 'a%20b=c%20d')
+})
From 53a444dd4a38d094f2f917a5aa57cfc32e9280e4 Mon Sep 17 00:00:00 2001
From: NeaByteLab <209737579+NeaByteLab@users.noreply.github.com>
Date: Thu, 25 Jun 2026 22:14:17 +0700
Subject: [PATCH 04/40] refactor(rendering)!: replace in-house engine with
@neabyte/dve
- Add core Rendering class delegating compile and render to dve
- Add View.watch invalidating templates by name on file change
- Emit view compiled, rendered, failed, and invalidated events
- Remove bespoke tokenizer, parser, evaluator, and watcher tree
- Remove rendering interface and engine test suites
- Return a Response from render with optional streaming
BREAKING CHANGE: rendering engine exports (Engine, Discover, Watcher) and the Rendering interface types are removed; render now returns a Response and rendering is provided by @neabyte/dve
---
src/core/Rendering.ts | 139 +++++
src/core/View.ts | 34 ++
src/interfaces/Rendering.ts | 164 ------
src/rendering/Discover.ts | 50 --
src/rendering/Engine.ts | 326 -----------
src/rendering/Watcher.ts | 45 --
src/rendering/engine/Eval.ts | 189 -------
src/rendering/engine/Expression.ts | 250 ---------
src/rendering/engine/Parser.ts | 127 -----
src/rendering/engine/Tokenizer.ts | 209 --------
src/rendering/engine/Utils.ts | 84 ---
src/rendering/engine/index.ts | 6 -
src/rendering/index.ts | 4 -
tests/core/Rendering.test.ts | 64 +++
tests/core/View.test.ts | 19 +
tests/rendering/Discover.test.ts | 42 --
tests/rendering/Engine.test.ts | 623 ----------------------
tests/rendering/Watcher.test.ts | 116 ----
tests/rendering/engine/Eval.test.ts | 275 ----------
tests/rendering/engine/Expression.test.ts | 168 ------
tests/rendering/engine/Parser.test.ts | 176 ------
tests/rendering/engine/Tokenizer.test.ts | 178 -------
tests/rendering/engine/Utils.test.ts | 147 -----
23 files changed, 256 insertions(+), 3179 deletions(-)
create mode 100644 src/core/Rendering.ts
create mode 100644 src/core/View.ts
delete mode 100644 src/interfaces/Rendering.ts
delete mode 100644 src/rendering/Discover.ts
delete mode 100644 src/rendering/Engine.ts
delete mode 100644 src/rendering/Watcher.ts
delete mode 100644 src/rendering/engine/Eval.ts
delete mode 100644 src/rendering/engine/Expression.ts
delete mode 100644 src/rendering/engine/Parser.ts
delete mode 100644 src/rendering/engine/Tokenizer.ts
delete mode 100644 src/rendering/engine/Utils.ts
delete mode 100644 src/rendering/engine/index.ts
delete mode 100644 src/rendering/index.ts
create mode 100644 tests/core/Rendering.test.ts
create mode 100644 tests/core/View.test.ts
delete mode 100644 tests/rendering/Discover.test.ts
delete mode 100644 tests/rendering/Engine.test.ts
delete mode 100644 tests/rendering/Watcher.test.ts
delete mode 100644 tests/rendering/engine/Eval.test.ts
delete mode 100644 tests/rendering/engine/Expression.test.ts
delete mode 100644 tests/rendering/engine/Parser.test.ts
delete mode 100644 tests/rendering/engine/Tokenizer.test.ts
delete mode 100644 tests/rendering/engine/Utils.test.ts
diff --git a/src/core/Rendering.ts b/src/core/Rendering.ts
new file mode 100644
index 0000000..6c264d6
--- /dev/null
+++ b/src/core/Rendering.ts
@@ -0,0 +1,139 @@
+import type * as Types from '@interfaces/index.ts'
+import * as Core from '@core/index.ts'
+import DVE from '@neabyte/dve'
+
+/**
+ * Template view rendering engine.
+ * @description Compiles, caches, and renders DVE templates.
+ */
+export class Rendering {
+ /** Underlying DVE engine instance */
+ readonly #dve: DVE
+ /** Base directory for template files */
+ readonly #directory: string
+ /** Compiled template cache by path */
+ readonly #cache = new Map()
+ /** Optional event emitter for render events */
+ readonly #emit: Types.EventFn | null
+
+ /**
+ * Construct rendering engine instance.
+ * @description Configures directory, limits, and event emitter.
+ * @param options - Rendering configuration options
+ * @param emit - Optional event emitter callback
+ */
+ constructor(options: Types.RenderingOptions, emit: Types.EventFn | null = null) {
+ this.#directory = options.directory ?? './views'
+ this.#emit = emit
+ this.#dve = new DVE({
+ resolveInclude: (path) => Deno.readTextFileSync(this.#resolvePath(path)),
+ ...(options.maxIterations !== undefined && { maxIterations: options.maxIterations }),
+ ...(options.maxRenderIterations !== undefined && {
+ maxRenderIterations: options.maxRenderIterations
+ }),
+ ...(options.maxOutputSize !== undefined && { maxOutputSize: options.maxOutputSize }),
+ ...(options.maxTemplateSize !== undefined && { maxTemplateSize: options.maxTemplateSize })
+ })
+ }
+
+ /** Base directory for template files */
+ get directory(): string {
+ return this.#directory
+ }
+
+ /**
+ * Invalidate cached compiled template.
+ * @description Removes cache entry and emits refresh event.
+ * @param template - Template name to invalidate
+ */
+ invalidate(template: string): void {
+ this.#cache.delete(this.#resolvePath(template))
+ if (this.#emit !== null) {
+ this.#emit(Core.Observability.internalEvent('view:invalidated', { paths: [template] }))
+ }
+ }
+
+ /**
+ * Render template into response.
+ * @description Compiles template then streams or renders output.
+ * @param template - Template name to render
+ * @param data - View data for template
+ * @param options - Render options like status
+ * @returns Promise resolving to rendered response
+ * @throws When template compile or render fails
+ */
+ async render(
+ template: string,
+ data: Types.ViewData,
+ options: Types.RenderInit
+ ): Promise {
+ const start = this.#emit !== null ? performance.now() : 0
+ try {
+ const compiled = await this.#compile(template)
+ const headers = { 'Content-Type': 'text/html; charset=utf-8' }
+ const status = options.status ?? 200
+ const body = options.stream === true
+ ? this.#dve.renderStream(compiled, data, template)
+ : this.#dve.render(compiled, data, template)
+ if (this.#emit !== null) {
+ this.#emit(
+ Core.Observability.internalEvent('view:rendered', {
+ path: template,
+ durationMs: performance.now() - start
+ })
+ )
+ }
+ return new Core.API.Response(body, { status, headers })
+ } catch (renderError) {
+ if (this.#emit !== null) {
+ this.#emit(
+ Core.Observability.internalEvent('view:failed', {
+ path: template,
+ error: renderError instanceof Error ? renderError : new Error(String(renderError))
+ })
+ )
+ }
+ throw renderError
+ }
+ }
+
+ /**
+ * Compile template with caching.
+ * @description Reads, compiles, and caches template once.
+ * @param template - Template name to compile
+ * @returns Promise resolving to compiled result
+ */
+ async #compile(template: string): Promise {
+ const path = this.#resolvePath(template)
+ const cached = this.#cache.get(path)
+ if (cached !== undefined) {
+ return cached
+ }
+ const start = this.#emit !== null ? performance.now() : 0
+ const compiled = this.#dve.compile(await Deno.readTextFile(path), template)
+ this.#cache.set(path, compiled)
+ if (this.#emit !== null) {
+ this.#emit(
+ Core.Observability.internalEvent('view:compiled', {
+ path: template,
+ durationMs: performance.now() - start
+ })
+ )
+ }
+ return compiled
+ }
+
+ /**
+ * Resolve template into file path.
+ * @description Normalizes path and appends DVE extension.
+ * @param template - Template name to resolve
+ * @returns Absolute template file path
+ */
+ #resolvePath(template: string): string {
+ const normalized = template.replace(/\\/g, '/').replace(/^\/+/, '')
+ const withExtension = normalized.toLowerCase().endsWith(Core.Constant.dveExtension)
+ ? normalized
+ : `${normalized}${Core.Constant.dveExtension}`
+ return `${this.#directory.replace(/\/+$/, '')}/${withExtension}`
+ }
+}
diff --git a/src/core/View.ts b/src/core/View.ts
new file mode 100644
index 0000000..6857754
--- /dev/null
+++ b/src/core/View.ts
@@ -0,0 +1,34 @@
+import * as Core from '@core/index.ts'
+import { Superwatcher } from '@neabyte/superwatcher'
+import nodePath from 'node:path'
+
+/**
+ * Template directory file watcher.
+ * @description Invalidates view cache on template changes.
+ */
+export class View {
+ /**
+ * Watch template directory for changes.
+ * @description Invalidates engine cache on DVE file events.
+ * @param engine - Rendering engine to invalidate
+ * @returns Disposer function stopping the watcher
+ */
+ static watch(engine: Core.Rendering): () => void {
+ const resolvedDir = nodePath.resolve(engine.directory)
+ if (!Core.Handler.isDirectory(resolvedDir)) {
+ return () => {}
+ }
+ const watcher = new Superwatcher({
+ path: resolvedDir,
+ debounceMs: Core.Constant.templateDebounceMs,
+ ignore: [(path) => !path.endsWith(Core.Constant.dveExtension)],
+ onChange(events) {
+ for (const event of events) {
+ engine.invalidate(event.path.slice(resolvedDir.length + 1))
+ }
+ }
+ })
+ watcher.start()
+ return () => watcher.dispose()
+ }
+}
diff --git a/src/interfaces/Rendering.ts b/src/interfaces/Rendering.ts
deleted file mode 100644
index 706af5f..0000000
--- a/src/interfaces/Rendering.ts
+++ /dev/null
@@ -1,164 +0,0 @@
-import type * as Types from '@interfaces/index.ts'
-
-/** Compiled DVE template result. */
-export interface CompileResult {
- /** Parsed AST node array */
- readonly ast: readonly AstNode[]
-}
-
-/** DVE template parser stack frame. */
-export interface DveStackFrame {
- /** True when inside else branch */
- inElse: boolean
- /** Block node type discriminant */
- readonly kind: AstBlockKind
- /** Parent block AST node */
- readonly node: AstBlockNode
-}
-
-/** Rendering engine constructor options. */
-export interface EngineOptions {
- /** Optional lifecycle event emitter */
- readonly emit?: Types.EventEmit
- /** Maximum loop iterations per #each block */
- readonly maxIterations?: number
- /** Maximum #each body executions per render */
- readonly maxRenderIterations?: number
- /** Maximum total output characters per render */
- readonly maxOutputSize?: number
- /** Directory path for template views */
- readonly viewsDir: string
-}
-
-/** Per-render cumulative resource budget. */
-export interface RenderBudget {
- /** Total #each body executions this render */
- iterations: number
- /** Total output characters this render */
- outputSize: number
-}
-
-/**
- * View engine for templates.
- * @description Renders templates to string or readable stream.
- */
-export interface ViewEngine {
- /**
- * Render template to string.
- * @param templatePath - Path to template file
- * @param data - Template data record
- * @returns Promise resolving to rendered HTML
- */
- render(...args: TemplateArgs): Promise
- /**
- * Render template to readable stream.
- * @param templatePath - Path to template file
- * @param data - Template data record
- * @returns Promise resolving to a ReadableStream of rendered output
- */
- streamRender(...args: TemplateArgs): Promise
-}
-
-/**
- * Watchable engine for cache invalidation.
- * @description Supports file watching and cache refresh.
- */
-export interface WatchableEngine extends Pick {
- /**
- * Invalidate cached template file.
- * @param absPath - Absolute path to invalidate
- */
- invalidateFile(absPath: string): void
- /**
- * Emit view:refreshed for changed paths.
- * @param paths - Absolute paths that were refreshed
- */
- notifyRefresh(paths: readonly string[]): void
- /** Refresh all template paths */
- refreshPaths(): void
-}
-
-/** Arithmetic sign for expressions. */
-export type ArithmeticSign = '+' | '-'
-
-/** Block-level AST node type discriminants. */
-export type AstBlockKind = AstBlockNode['type']
-
-/** Block-level AST node with children. */
-export type AstBlockNode = Extract
-
-/** DVE template AST node union. */
-export type AstNode =
- | (Types.TaggedVariant<'type', 'each', { path: string; itemName: string }> & { nodes: AstNode[] })
- | (Types.TaggedVariant<'type', 'if', { path: string }> & {
- thenNodes: AstNode[]
- elseNodes: AstNode[]
- })
- | Types.TaggedVariant<'type', 'include', { templatePath: string }>
- | Types.TaggedVariant<'type', 'text', { value: string }>
- | Types.TaggedVariant<'type', 'var', { path: string; raw: boolean }>
-
-/** AST node type discriminant values. */
-export type AstNodeType = AstNode['type']
-
-/** Binary operator literals. */
-export type BinaryOp =
- | '!='
- | '!=='
- | '%'
- | '&&'
- | '*'
- | '/'
- | '<'
- | '<='
- | '=='
- | '==='
- | '>'
- | '>='
- | '??'
- | '||'
- | ArithmeticSign
-
-/** DVE expression AST node union. */
-export type ExprNode =
- | Types.TaggedVariant<'type', 'binary', { op: BinaryOp; left: ExprNode; right: ExprNode }>
- | Types.TaggedVariant<'type', 'ident', { name: string }>
- | Types.TaggedVariant<'type', 'literal', { value: string | number }>
- | Types.TaggedVariant<'type', 'member', { object: ExprNode; property: string }>
- | Types.TaggedVariant<
- 'type',
- 'ternary',
- { test: ExprNode; consequent: ExprNode; alternate: ExprNode }
- >
- | Types.TaggedVariant<'type', 'unary', { op: UnaryOp; arg: ExprNode }>
-
-/** Expression node type discriminant values. */
-export type ExprNodeType = ExprNode['type']
-
-/** Node shape exposing op discriminant. */
-export type ExprOpCarrier = Types.TagCarrier<'op'>
-
-/** DVE expression evaluator token. */
-export type ExprToken =
- | Types.TaggedVariant<'kind', 'ident', { value: string }>
- | Types.TaggedVariant<'kind', 'number', { value: number }>
- | Types.TaggedVariant<'kind', 'op', { value: TokenOp }>
- | Types.TaggedVariant<'kind', 'string', { value: string }>
-
-/** Expression token kind discriminant values. */
-export type ExprTokenKind = ExprToken['kind']
-
-/** Node shape exposing type discriminant. */
-export type ExprTypeCarrier = Types.TagCarrier<'type'>
-
-/** Structural operators in expression tokens. */
-export type StructuralOp = '(' | ')' | '.' | ':' | '?' | '?.'
-
-/** Template method parameter tuple. */
-export type TemplateArgs = [templatePath: string, data?: Types.DataRecord]
-
-/** All operator literals in tokens. */
-export type TokenOp = BinaryOp | StructuralOp | UnaryOp
-
-/** Unary operator literals. */
-export type UnaryOp = '!' | ArithmeticSign
diff --git a/src/rendering/Discover.ts b/src/rendering/Discover.ts
deleted file mode 100644
index af7fa77..0000000
--- a/src/rendering/Discover.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import * as Core from '@core/index.ts'
-
-/**
- * Scan directory for .dve templates.
- * @description Collects relative paths for Engine validation.
- */
-export class Discover {
- /**
- * Discover .dve template paths.
- * @description Recursively scans viewsDir for templates.
- * @param viewsDir - Root directory to scan
- * @returns Set of relative template paths
- */
- static async discoverPaths(viewsDir: string): Promise> {
- const collectedPaths = new Set()
- await Discover.collectPaths(viewsDir, '', collectedPaths)
- return collectedPaths
- }
-
- /**
- * Collect .dve paths recursively.
- * @description Walks directories and accumulates relative paths.
- * @param targetDir - Directory to read
- * @param basePath - Current relative path prefix
- * @param collectedPaths - Set to add discovered paths
- */
- private static async collectPaths(
- targetDir: string,
- basePath: string,
- collectedPaths: Set
- ): Promise {
- try {
- for await (const dirEntry of Deno.readDir(targetDir)) {
- const fullPath = `${targetDir}/${dirEntry.name}`
- const relativePath = basePath ? `${basePath}/${dirEntry.name}` : dirEntry.name
- if (dirEntry.isDirectory) {
- await Discover.collectPaths(fullPath, relativePath, collectedPaths)
- } else if (
- dirEntry.isFile && relativePath.toLowerCase().endsWith(Core.Constant.dveExtension)
- ) {
- collectedPaths.add(relativePath)
- }
- }
- } catch (error) {
- if (!(error instanceof Deno.errors.NotFound)) {
- throw error
- }
- }
- }
-}
diff --git a/src/rendering/Engine.ts b/src/rendering/Engine.ts
deleted file mode 100644
index f70f210..0000000
--- a/src/rendering/Engine.ts
+++ /dev/null
@@ -1,326 +0,0 @@
-import type * as Types from '@interfaces/index.ts'
-import * as Core from '@core/index.ts'
-import * as Rendering from '@rendering/index.ts'
-import * as EngineParts from '@rendering/engine/index.ts'
-
-/**
- * Template rendering engine.
- * @description Compiles and renders DVE templates with cache.
- */
-export class Engine implements Types.ViewEngine, Types.WatchableEngine {
- /** Default views directory */
- private readonly defaultViewsDir: string
- /** Max iterations per #each block */
- private readonly maxIterations: number
- /** Max total #each body executions per render */
- private readonly maxRenderIterations: number
- /** Max total output characters per render */
- private readonly maxOutputSize: number
- /** Compiled template cache */
- private readonly compileCache = new Map()
- /** Optional lifecycle event emitter */
- private readonly emit: Types.EventEmit | undefined
- /** Discovered template path cache */
- private discoveredPaths: Set | null = null
-
- /**
- * Create new engine instance.
- * @description Stores default viewsDir from options.
- * @param options - Engine configuration options
- */
- constructor(options: Types.EngineOptions) {
- this.defaultViewsDir = options.viewsDir
- this.maxIterations = options.maxIterations ?? Core.Constant.defaultMaxIterations
- this.maxRenderIterations = options.maxRenderIterations ??
- Core.Constant.defaultMaxRenderIterations
- this.maxOutputSize = options.maxOutputSize ?? Core.Constant.defaultMaxOutputSize
- this.emit = options.emit
- }
-
- /** Views directory for path resolution */
- get viewsDir(): string {
- return this.defaultViewsDir
- }
-
- /**
- * Invalidate cached template by absolute path.
- * @description Clears file and compile caches for the path.
- * @param absPath - Absolute template file path
- */
- invalidateFile(absPath: string): void {
- this.compileCache.delete(absPath)
- }
-
- /**
- * Emit view:refreshed for changed paths.
- * @description Called by watcher after invalidating changed paths.
- * @param paths - Absolute paths that were refreshed
- */
- notifyRefresh(paths: readonly string[]): void {
- this.emit?.(Core.Observability.internalEvent('view:refreshed', { paths: [...paths] }))
- }
-
- /** Reset discovered template paths */
- refreshPaths(): void {
- this.discoveredPaths = null
- }
-
- /**
- * Render template with data.
- * @description Loads template and produces final HTML.
- * @param templatePath - Relative template path
- * @param data - Template scope data
- * @param depth - Current include nesting depth
- * @returns Rendered HTML string
- * @throws {Deno.errors.NotFound} When template path not discovered
- * @throws {Deno.errors.InvalidData} When include depth exceeded
- */
- async render(
- templatePath: string,
- data: Types.DataRecord = {},
- depth = 0,
- budget?: Types.RenderBudget
- ): Promise {
- if (depth > Core.Constant.maxIncludeDepth) {
- throw new Deno.errors.InvalidData(
- `Template include depth exceeded ${Core.Constant.maxIncludeDepth} for "${templatePath}"`
- )
- }
- const renderStart = depth === 0 ? performance.now() : 0
- const renderBudget = budget ?? { iterations: 0, outputSize: 0 }
- if (depth > 0) {
- const compiled = await this.resolveTemplate(templatePath)
- return await this.renderNodes(compiled.ast, data, this.defaultViewsDir, depth, renderBudget)
- }
- try {
- const compiled = await this.resolveTemplate(templatePath)
- const outputHtml = await this.renderNodes(
- compiled.ast,
- data,
- this.defaultViewsDir,
- depth,
- renderBudget
- )
- this.emit?.(
- Core.Observability.internalEvent('view:rendered', {
- path: templatePath,
- durationMs: performance.now() - renderStart
- })
- )
- return outputHtml
- } catch (renderError) {
- const error = renderError instanceof Error ? renderError : new Error(String(renderError))
- this.emit?.(Core.Observability.internalEvent('view:error', { path: templatePath, error }))
- throw renderError
- }
- }
-
- /**
- * Render template with streaming.
- * @description Resolves template up front, then streams compiled AST.
- * @param templatePath - Relative template path
- * @param data - Template scope data
- * @returns Promise resolving to a ReadableStream with HTML content
- * @throws {Deno.errors.NotFound} When template not found
- * @throws {Deno.errors.InvalidData} When the template fails to compile
- */
- async streamRender(templatePath: string, data: Types.DataRecord = {}): Promise {
- const compiled = await this.resolveTemplate(templatePath)
- const { readable, writable } = new TransformStream()
- this.renderStream(compiled, templatePath, data, writable).catch((error: Error) => {
- this.emit?.(Core.Observability.internalEvent('view:error', { path: templatePath, error }))
- })
- return readable
- }
-
- /**
- * Compile template and cache.
- * @description Parses template text into AST nodes.
- * @param absTemplatePath - Absolute path to template
- * @returns Compile result with AST
- */
- private async compileTemplate(absTemplatePath: string): Promise {
- const cachedCompile = this.compileCache.get(absTemplatePath)
- if (cachedCompile) {
- return cachedCompile
- }
- const compileStart = performance.now()
- const template = await Deno.readTextFile(absTemplatePath)
- const ast = EngineParts.Parser.parse(template)
- const compileResult = { ast }
- this.compileCache.set(absTemplatePath, compileResult)
- this.emit?.(
- Core.Observability.internalEvent('view:compiled', {
- path: absTemplatePath,
- durationMs: performance.now() - compileStart
- })
- )
- return compileResult
- }
-
- /**
- * Render node to chunk.
- * @description Renders individual node to HTML chunk.
- * @param node - AST node to render
- * @param data - Template scope data
- * @param viewsDir - Root directory for includes
- * @param depth - Current include nesting depth
- * @param budget - Per-render cumulative resource budget
- * @returns HTML chunk string or null
- */
- private async renderChunk(
- node: Types.AstNode,
- data: Types.DataRecord,
- viewsDir: string,
- depth: number,
- budget: Types.RenderBudget
- ): Promise {
- if (node.type === 'text') {
- return node.value
- }
- if (node.type === 'var') {
- const lookupValue = EngineParts.Eval.evaluate(node.path, data)
- const stringValue = lookupValue === null || lookupValue === undefined
- ? ''
- : String(lookupValue)
- return node.raw ? stringValue : EngineParts.Utils.escape(stringValue)
- }
- if (node.type === 'include') {
- return await this.render(node.templatePath, data, depth + 1, budget)
- }
- if (node.type === 'if') {
- const lookupValue = EngineParts.Eval.evaluate(node.path, data)
- const nodes = lookupValue ? node.thenNodes : node.elseNodes
- return await this.renderNodes(nodes, data, viewsDir, depth, budget)
- }
- if (node.type === 'each') {
- const lookupValue = EngineParts.Eval.evaluate(node.path, data)
- if (!Array.isArray(lookupValue)) {
- return null
- }
- if (lookupValue.length > this.maxIterations) {
- throw new Deno.errors.InvalidData(
- `Template #each exceeded ${this.maxIterations} iterations (got ${lookupValue.length})`
- )
- }
- const length = lookupValue.length
- budget.iterations += length
- if (budget.iterations > this.maxRenderIterations) {
- throw new Deno.errors.InvalidData(
- `Template render exceeded ${this.maxRenderIterations} total iterations`
- )
- }
- let outputHtml = ''
- for (let index = 0; index < length; index++) {
- const item = lookupValue[index]
- const scopeData: Types.DataRecord = {
- ...data,
- [node.itemName]: item,
- '@index': index,
- '@first': index === 0,
- '@last': index === length - 1,
- '@length': length
- }
- outputHtml += await this.renderNodes(node.nodes, scopeData, viewsDir, depth, budget)
- }
- return outputHtml
- }
- return null
- }
-
- /**
- * Render AST nodes to HTML.
- * @description Evaluates variables, includes, and blocks.
- * @param ast - Parsed template AST nodes
- * @param data - Current scope data
- * @param viewsDir - Root directory for includes
- * @param depth - Current include nesting depth
- * @param budget - Per-render cumulative resource budget
- * @returns Rendered HTML string
- */
- private async renderNodes(
- ast: readonly Types.AstNode[],
- data: Types.DataRecord,
- viewsDir: string,
- depth: number,
- budget: Types.RenderBudget
- ): Promise {
- let outputHtml = ''
- for (const node of ast) {
- const chunk = await this.renderChunk(node, data, viewsDir, depth, budget)
- if (chunk) {
- budget.outputSize += chunk.length
- if (budget.outputSize > this.maxOutputSize) {
- throw new Deno.errors.InvalidData(
- `Template render exceeded ${this.maxOutputSize} output characters`
- )
- }
- outputHtml += chunk
- }
- }
- return outputHtml
- }
-
- /**
- * Render compiled template nodes to stream.
- * @description Streams HTML output progressively from an already-compiled AST.
- * @param compiled - Pre-resolved compiled template
- * @param templatePath - Relative template path for events
- * @param data - Template scope data
- * @param writable - Writable stream for output
- */
- private async renderStream(
- compiled: Types.CompileResult,
- templatePath: string,
- data: Types.DataRecord,
- writable: WritableStream
- ): Promise {
- const writer = writable.getWriter()
- const renderStart = performance.now()
- const budget: Types.RenderBudget = { iterations: 0, outputSize: 0 }
- try {
- for (const node of compiled.ast) {
- const chunk = await this.renderChunk(node, data, this.defaultViewsDir, 0, budget)
- if (chunk) {
- budget.outputSize += chunk.length
- if (budget.outputSize > this.maxOutputSize) {
- throw new Deno.errors.InvalidData(
- `Template render exceeded ${this.maxOutputSize} output characters`
- )
- }
- await writer.write(Core.Constant.encoder.encode(chunk))
- }
- }
- this.emit?.(
- Core.Observability.internalEvent('view:rendered', {
- path: templatePath,
- durationMs: performance.now() - renderStart
- })
- )
- } finally {
- await writer.close()
- }
- }
-
- /**
- * Resolve template path to compiled result.
- * @description Discovers paths, normalizes, validates, and compiles.
- * @param templatePath - Relative template path
- * @returns Compiled template with AST
- * @throws {Deno.errors.NotFound} When template not found
- */
- private async resolveTemplate(templatePath: string): Promise {
- if (this.discoveredPaths === null) {
- this.discoveredPaths = await Rendering.Discover.discoverPaths(this.defaultViewsDir)
- }
- const normalizedPath = templatePath.replace(/\\/g, '/')
- const pathWithExtension = normalizedPath.toLowerCase().endsWith(Core.Constant.dveExtension)
- ? normalizedPath
- : `${normalizedPath}${Core.Constant.dveExtension}`
- if (!this.discoveredPaths.has(pathWithExtension)) {
- throw new Deno.errors.NotFound(`Template "${templatePath}" not found in views directory`)
- }
- const absPath = EngineParts.Utils.join(this.defaultViewsDir, pathWithExtension)
- return await this.compileTemplate(absPath)
- }
-}
diff --git a/src/rendering/Watcher.ts b/src/rendering/Watcher.ts
deleted file mode 100644
index 2bf8373..0000000
--- a/src/rendering/Watcher.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import type * as Types from '@interfaces/index.ts'
-import * as Core from '@core/index.ts'
-import * as EngineParts from '@rendering/engine/index.ts'
-import { Superwatcher } from '@neabyte/superwatcher'
-import nodePath from 'node:path'
-
-/**
- * File watcher for DVE templates.
- * @description Watches viewsDir and invalidates Engine caches on change.
- */
-export class Watcher {
- /**
- * Start watching template directory.
- * @description Uses Superwatcher with cache invalidation.
- * @param engine - Engine instance to invalidate
- * @returns Stop handle releasing the watcher
- */
- static watch(engine: Types.WatchableEngine): () => void {
- const viewsDir = engine.viewsDir
- const resolvedDir = nodePath.resolve(viewsDir)
- if (!Core.Handler.isDirectory(resolvedDir)) {
- return () => {}
- }
- const watcher = new Superwatcher({
- path: resolvedDir,
- debounceMs: Core.Constant.templateDebounceMs,
- ignore: [(path: string) => !path.endsWith(Core.Constant.dveExtension)],
- onChange(events) {
- const refreshedPaths: string[] = []
- for (const event of events) {
- const relativePath = event.path.slice(resolvedDir.length + 1)
- const absPath = EngineParts.Utils.join(viewsDir, relativePath)
- engine.invalidateFile(absPath)
- refreshedPaths.push(absPath)
- }
- if (events.length > 0) {
- engine.refreshPaths()
- engine.notifyRefresh(refreshedPaths)
- }
- }
- })
- watcher.start()
- return () => watcher.dispose()
- }
-}
diff --git a/src/rendering/engine/Eval.ts b/src/rendering/engine/Eval.ts
deleted file mode 100644
index 50498d3..0000000
--- a/src/rendering/engine/Eval.ts
+++ /dev/null
@@ -1,189 +0,0 @@
-import type * as Types from '@interfaces/index.ts'
-import * as Core from '@core/index.ts'
-import * as EngineParts from '@rendering/engine/index.ts'
-
-/**
- * DVE expression evaluator.
- * @description Evaluates expression AST against scope object.
- */
-export class Eval {
- /**
- * Evaluate expression in scope.
- * @description Tokenizes, parses, and evaluates expression.
- * @param expression - Expression source text
- * @param scope - Scope data for identifiers
- * @returns Evaluated expression value
- * @throws {Deno.errors.InvalidData} When expression parse fails
- */
- static evaluate(expression: string, scope: Types.DataRecord): unknown {
- const trimmedExpression = expression.trim()
- if (!trimmedExpression) {
- return undefined
- }
- if (Core.Constant.simplePathRegex.test(trimmedExpression)) {
- return EngineParts.Utils.lookup(scope, trimmedExpression)
- }
- const exprTokens = EngineParts.Tokenizer.tokenize(trimmedExpression)
- const exprParser = new EngineParts.Expression(exprTokens)
- const astNode = exprParser.parse()
- exprParser.assertEnd()
- return Eval.evalNode(astNode, scope)
- }
-
- /**
- * Evaluate binary expression node.
- * @description Short-circuits logical ops, else applies the operator.
- * @param exprNode - Binary AST node
- * @param scope - Scope data for identifiers
- * @returns Evaluated value
- * @throws {Deno.errors.InvalidData} When the operator is not whitelisted
- */
- private static evalBinary(
- exprNode: Extract,
- scope: Types.DataRecord
- ): unknown {
- if (exprNode.op === '&&') {
- const leftValue = Eval.evalNode(exprNode.left, scope)
- return leftValue ? Eval.evalNode(exprNode.right, scope) : leftValue
- }
- if (exprNode.op === '||') {
- const leftValue = Eval.evalNode(exprNode.left, scope)
- return leftValue ? leftValue : Eval.evalNode(exprNode.right, scope)
- }
- if (exprNode.op === '??') {
- const leftValue = Eval.evalNode(exprNode.left, scope)
- return leftValue === null || leftValue === undefined
- ? Eval.evalNode(exprNode.right, scope)
- : leftValue
- }
- const leftValue = Eval.evalNode(exprNode.left, scope)
- const rightValue = Eval.evalNode(exprNode.right, scope)
- switch (exprNode.op) {
- case '===':
- return leftValue === rightValue
- case '!==':
- return leftValue !== rightValue
- case '==':
- return leftValue == rightValue
- case '!=':
- return leftValue != rightValue
- case '>':
- return (leftValue as never) > (rightValue as never)
- case '<':
- return (leftValue as never) < (rightValue as never)
- case '>=':
- return (leftValue as never) >= (rightValue as never)
- case '<=':
- return (leftValue as never) <= (rightValue as never)
- case '+':
- return (leftValue as never) + (rightValue as never)
- case '-':
- return (leftValue as never) - (rightValue as never)
- case '*':
- return (leftValue as never) * (rightValue as never)
- case '/':
- return (leftValue as never) / (rightValue as never)
- case '%':
- return (leftValue as never) % (rightValue as never)
- default:
- throw new Deno.errors.InvalidData(
- `Unsupported DVE binary operator "${(exprNode as Types.ExprOpCarrier).op}"`
- )
- }
- }
-
- /**
- * Resolve identifier to keyword or scope.
- * @description Keywords return literals, names read own scope properties.
- * @param identName - Identifier name
- * @param scope - Scope data for identifiers
- * @returns Resolved value
- */
- private static evalIdent(identName: string, scope: Types.DataRecord): unknown {
- switch (identName) {
- case 'true':
- return true
- case 'false':
- return false
- case 'null':
- return null
- case 'undefined':
- return undefined
- default:
- return Object.hasOwn(scope, identName) ? scope[identName] : undefined
- }
- }
-
- /**
- * Resolve member access to own property.
- * @description Reads only own properties via Object.hasOwn.
- * @param exprNode - Member AST node
- * @param scope - Scope data for identifiers
- * @returns Resolved value or undefined
- */
- private static evalMember(
- exprNode: Extract,
- scope: Types.DataRecord
- ): unknown {
- const objectValue = Eval.evalNode(exprNode.object, scope)
- return EngineParts.Utils.readOwn(objectValue, exprNode.property)
- }
-
- /**
- * Evaluate single AST node.
- * @description Whitelist dispatch rejecting any unknown node type.
- * @param exprNode - Expression AST node
- * @param scope - Scope data for identifiers
- * @returns Evaluated value
- * @throws {Deno.errors.InvalidData} When the node type is not whitelisted
- */
- private static evalNode(exprNode: Types.ExprNode, scope: Types.DataRecord): unknown {
- switch (exprNode.type) {
- case 'literal':
- return exprNode.value
- case 'ident':
- return Eval.evalIdent(exprNode.name, scope)
- case 'member':
- return Eval.evalMember(exprNode, scope)
- case 'unary':
- return Eval.evalUnary(exprNode, scope)
- case 'binary':
- return Eval.evalBinary(exprNode, scope)
- case 'ternary':
- return Eval.evalNode(exprNode.test, scope)
- ? Eval.evalNode(exprNode.consequent, scope)
- : Eval.evalNode(exprNode.alternate, scope)
- default:
- throw new Deno.errors.InvalidData(
- `Unsupported DVE expression node "${(exprNode as Types.ExprTypeCarrier).type}"`
- )
- }
- }
-
- /**
- * Evaluate unary expression node.
- * @description Rejects any operator outside the unary grammar.
- * @param exprNode - Unary AST node
- * @param scope - Scope data for identifiers
- * @returns Evaluated value
- * @throws {Deno.errors.InvalidData} When the operator is not whitelisted
- */
- private static evalUnary(
- exprNode: Extract,
- scope: Types.DataRecord
- ): unknown {
- const argValue = Eval.evalNode(exprNode.arg, scope)
- switch (exprNode.op) {
- case '!':
- return !argValue
- case '+':
- return typeof argValue === 'number' ? argValue : Number(argValue)
- case '-':
- return -(typeof argValue === 'number' ? argValue : Number(argValue))
- default:
- throw new Deno.errors.InvalidData(
- `Unsupported DVE unary operator "${(exprNode as Types.ExprOpCarrier).op}"`
- )
- }
- }
-}
diff --git a/src/rendering/engine/Expression.ts b/src/rendering/engine/Expression.ts
deleted file mode 100644
index 3b924e9..0000000
--- a/src/rendering/engine/Expression.ts
+++ /dev/null
@@ -1,250 +0,0 @@
-import type * as Types from '@interfaces/index.ts'
-
-/**
- * DVE expression parser.
- * @description Builds expression AST from token list.
- */
-export class Expression {
- /** Current token stream index */
- private tokenIndex = 0
-
- /**
- * Create parser for token list.
- * @description Holds token array for parsing.
- * @param tokens - Expression tokens from tokenizer
- */
- constructor(private readonly tokens: Types.ExprToken[]) {}
-
- /** Assert no remaining tokens */
- assertEnd(): void {
- if (this.tokenIndex < this.tokens.length) {
- throw new Deno.errors.InvalidData('Unexpected token in DVE expression')
- }
- }
-
- /** Parse tokens into expression AST */
- parse(): Types.ExprNode {
- return this.parseTry()
- }
-
- /** Advance and return current token */
- private consume(): Types.ExprToken | undefined {
- const currentToken = this.tokens[this.tokenIndex]
- this.tokenIndex++
- return currentToken
- }
-
- /**
- * Consume token and require operator.
- * @description Throws if current token is not given op.
- * @param expectedOp - Expected operator string
- * @throws {Deno.errors.InvalidData} When token is not the operator
- */
- private expectOp(expectedOp: Types.TokenOp): void {
- const currentToken = this.consume()
- if (!currentToken || currentToken.kind !== 'op' || currentToken.value !== expectedOp) {
- throw new Deno.errors.InvalidData(`Expected '${expectedOp}' in DVE expression`)
- }
- }
-
- /**
- * Match and consume operator if present.
- * @description Advances only when current op equals value.
- * @param expectedOp - Operator to match
- * @returns True when matched and consumed
- */
- private matchOp(expectedOp: Types.TokenOp): boolean {
- const currentToken = this.peek()
- if (currentToken?.kind === 'op' && currentToken.value === expectedOp) {
- this.tokenIndex++
- return true
- }
- return false
- }
-
- /** Parse additive expression */
- private parseAdd(): Types.ExprNode {
- let exprNode = this.parseMul()
- while (true) {
- const currentToken = this.peek()
- if (
- currentToken?.kind === 'op' &&
- (currentToken.value === '+' || currentToken.value === '-')
- ) {
- this.consume()
- const rightNode = this.parseMul()
- exprNode = { type: 'binary', op: currentToken.value, left: exprNode, right: rightNode }
- continue
- }
- return exprNode
- }
- }
-
- /** Parse logical AND expression */
- private parseAnd(): Types.ExprNode {
- let exprNode = this.parseEq()
- while (this.matchOp('&&')) {
- const rightNode = this.parseEq()
- exprNode = { type: 'binary', op: '&&', left: exprNode, right: rightNode }
- }
- return exprNode
- }
-
- /** Parse equality expression */
- private parseEq(): Types.ExprNode {
- let exprNode = this.parseRel()
- while (true) {
- const currentToken = this.peek()
- if (
- currentToken?.kind === 'op' &&
- (currentToken.value === '===' ||
- currentToken.value === '!==' ||
- currentToken.value === '==' ||
- currentToken.value === '!=')
- ) {
- this.consume()
- const rightNode = this.parseRel()
- exprNode = { type: 'binary', op: currentToken.value, left: exprNode, right: rightNode }
- continue
- }
- return exprNode
- }
- }
-
- /** Parse member access expression */
- private parseMem(): Types.ExprNode {
- let exprNode = this.parsePrim()
- while (true) {
- if (this.matchOp('.')) {
- const propToken = this.consume()
- if (!propToken || propToken.kind !== 'ident') {
- throw new Deno.errors.InvalidData('Expected identifier after "." in DVE expression')
- }
- exprNode = { type: 'member', object: exprNode, property: propToken.value }
- continue
- }
- if (this.matchOp('?.')) {
- const propToken = this.consume()
- if (!propToken || propToken.kind !== 'ident') {
- throw new Deno.errors.InvalidData('Expected identifier after "?." in DVE expression')
- }
- exprNode = { type: 'member', object: exprNode, property: propToken.value }
- continue
- }
- return exprNode
- }
- }
-
- /** Parse multiplicative expression */
- private parseMul(): Types.ExprNode {
- let exprNode = this.parseUn()
- while (true) {
- const currentToken = this.peek()
- if (
- currentToken?.kind === 'op' &&
- (currentToken.value === '*' || currentToken.value === '/' || currentToken.value === '%')
- ) {
- this.consume()
- const rightNode = this.parseUn()
- exprNode = { type: 'binary', op: currentToken.value, left: exprNode, right: rightNode }
- continue
- }
- return exprNode
- }
- }
-
- /** Parse nullish coalescing expression */
- private parseNil(): Types.ExprNode {
- let exprNode = this.parseOr()
- while (this.matchOp('??')) {
- const rightNode = this.parseOr()
- exprNode = { type: 'binary', op: '??', left: exprNode, right: rightNode }
- }
- return exprNode
- }
-
- /** Parse logical OR expression */
- private parseOr(): Types.ExprNode {
- let exprNode = this.parseAnd()
- while (this.matchOp('||')) {
- const rightNode = this.parseAnd()
- exprNode = { type: 'binary', op: '||', left: exprNode, right: rightNode }
- }
- return exprNode
- }
-
- /** Parse primary expression */
- private parsePrim(): Types.ExprNode {
- const currentToken = this.consume()
- if (!currentToken) {
- throw new Deno.errors.InvalidData('Unexpected end of DVE expression')
- }
- if (currentToken.kind === 'number') {
- return { type: 'literal', value: currentToken.value }
- }
- if (currentToken.kind === 'string') {
- return { type: 'literal', value: currentToken.value }
- }
- if (currentToken.kind === 'ident') {
- return { type: 'ident', name: currentToken.value }
- }
- if (currentToken.kind === 'op' && currentToken.value === '(') {
- const innerNode = this.parse()
- this.expectOp(')')
- return innerNode
- }
- throw new Deno.errors.InvalidData('Invalid primary in DVE expression')
- }
-
- /** Parse relational expression */
- private parseRel(): Types.ExprNode {
- let exprNode = this.parseAdd()
- while (true) {
- const currentToken = this.peek()
- if (
- currentToken?.kind === 'op' &&
- (currentToken.value === '>' ||
- currentToken.value === '<' ||
- currentToken.value === '>=' ||
- currentToken.value === '<=')
- ) {
- this.consume()
- const rightNode = this.parseAdd()
- exprNode = { type: 'binary', op: currentToken.value, left: exprNode, right: rightNode }
- continue
- }
- return exprNode
- }
- }
-
- /** Parse ternary and nullish */
- private parseTry(): Types.ExprNode {
- let exprNode = this.parseNil()
- if (this.matchOp('?')) {
- const consequent = this.parse()
- this.expectOp(':')
- const alternate = this.parse()
- exprNode = { type: 'ternary', test: exprNode, consequent, alternate }
- }
- return exprNode
- }
-
- /** Parse unary or member expression */
- private parseUn(): Types.ExprNode {
- const currentToken = this.peek()
- if (
- currentToken?.kind === 'op' &&
- (currentToken.value === '!' || currentToken.value === '+' || currentToken.value === '-')
- ) {
- this.consume()
- const argNode = this.parseUn()
- return { type: 'unary', op: currentToken.value as Types.UnaryOp, arg: argNode }
- }
- return this.parseMem()
- }
-
- /** Read current token without advancing */
- private peek(): Types.ExprToken | undefined {
- return this.tokens[this.tokenIndex]
- }
-}
diff --git a/src/rendering/engine/Parser.ts b/src/rendering/engine/Parser.ts
deleted file mode 100644
index 0b08f57..0000000
--- a/src/rendering/engine/Parser.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-import type * as Types from '@interfaces/index.ts'
-
-/**
- * DVE template parser.
- * @description Converts template text into AST nodes.
- */
-export class Parser {
- /**
- * Parse template into AST.
- * @description Extracts tags and builds block structures.
- * @param templateText - Raw template content
- * @returns List of AST nodes
- * @throws {Deno.errors.InvalidData} When template has unclosed blocks
- */
- static parse(templateText: string): Types.AstNode[] {
- const astNodes: Types.AstNode[] = []
- const templateTagRegex = /\{\{\{[\s\S]*?\}\}\}|\{\{[\s\S]*?\}\}/g
- let scanIndex = 0
- const frameStack: Types.DveStackFrame[] = []
- const appendAstNode = (node: Types.AstNode) => {
- const stackFrame = frameStack[frameStack.length - 1]
- if (!stackFrame) {
- astNodes.push(node)
- return
- }
- if (stackFrame.kind === 'if') {
- const ifNode = stackFrame.node as Extract
- if (stackFrame.inElse) {
- ifNode.elseNodes.push(node)
- } else {
- ifNode.thenNodes.push(node)
- }
- return
- }
- const eachNode = stackFrame.node as Extract
- eachNode.nodes.push(node)
- }
- let tagMatch: RegExpExecArray | null
- while ((tagMatch = templateTagRegex.exec(templateText)) !== null) {
- const rawTemplateTag = tagMatch[0] ?? ''
- const tagStartIndex = tagMatch.index
- if (tagStartIndex > scanIndex) {
- appendAstNode({ type: 'text', value: templateText.slice(scanIndex, tagStartIndex) })
- }
- scanIndex = tagStartIndex + rawTemplateTag.length
- if (rawTemplateTag.startsWith('{{{')) {
- const tagContent = rawTemplateTag.slice(3, -3).trim()
- if (tagContent) {
- appendAstNode({ type: 'var', path: tagContent, raw: true })
- }
- continue
- }
- const tagContent = rawTemplateTag.slice(2, -2).trim()
- if (!tagContent) {
- continue
- }
- if (tagContent.startsWith('>')) {
- const includeTemplatePath = tagContent.slice(1).trim()
- if (includeTemplatePath) {
- appendAstNode({ type: 'include', templatePath: includeTemplatePath })
- }
- continue
- }
- if (tagContent.startsWith('#if ')) {
- const dataPath = tagContent.slice(4).trim()
- const ifNode: Extract = {
- type: 'if',
- path: dataPath,
- thenNodes: [],
- elseNodes: []
- }
- appendAstNode(ifNode)
- frameStack.push({ kind: 'if', node: ifNode, inElse: false })
- continue
- }
- if (tagContent === 'else') {
- const stackFrame = frameStack[frameStack.length - 1]
- if (!stackFrame || stackFrame.kind !== 'if') {
- throw new Deno.errors.InvalidData('Unexpected {{else}} without matching {{#if}} block')
- }
- stackFrame.inElse = true
- continue
- }
- if (tagContent === '/if') {
- const stackFrame = frameStack[frameStack.length - 1]
- if (!stackFrame || stackFrame.kind !== 'if') {
- throw new Deno.errors.InvalidData('Unexpected {{/if}} without matching {{#if}} block')
- }
- frameStack.pop()
- continue
- }
- if (tagContent.startsWith('#each ')) {
- const eachClauseText = tagContent.slice(6).trim()
- const asClauseMatch = eachClauseText.match(/^(.+)\s+as\s+([a-zA-Z_$][a-zA-Z0-9_$]*)$/)
- const dataPath = (asClauseMatch?.[1] ?? eachClauseText).trim()
- const itemName = (asClauseMatch?.[2] ?? 'item').trim()
- const eachNode: Extract = {
- type: 'each',
- path: dataPath,
- itemName,
- nodes: []
- }
- appendAstNode(eachNode)
- frameStack.push({ kind: 'each', node: eachNode, inElse: false })
- continue
- }
- if (tagContent === '/each') {
- const stackFrame = frameStack[frameStack.length - 1]
- if (!stackFrame || stackFrame.kind !== 'each') {
- throw new Deno.errors.InvalidData('Unexpected {{/each}} without matching {{#each}} block')
- }
- frameStack.pop()
- continue
- }
- appendAstNode({ type: 'var', path: tagContent, raw: false })
- }
- if (scanIndex < templateText.length) {
- appendAstNode({ type: 'text', value: templateText.slice(scanIndex) })
- }
- if (frameStack.length > 0) {
- const unclosedFrame = frameStack[frameStack.length - 1]
- const blockLabel = unclosedFrame?.kind === 'each' ? '#each' : '#if'
- throw new Deno.errors.InvalidData(`Unclosed {{${blockLabel}}} block in DVE template`)
- }
- return astNodes
- }
-}
diff --git a/src/rendering/engine/Tokenizer.ts b/src/rendering/engine/Tokenizer.ts
deleted file mode 100644
index ee252ed..0000000
--- a/src/rendering/engine/Tokenizer.ts
+++ /dev/null
@@ -1,209 +0,0 @@
-import type * as Types from '@interfaces/index.ts'
-
-/**
- * DVE expression tokenizer.
- * @description Converts expression string into token list.
- */
-export class Tokenizer {
- /**
- * Tokenize expression into tokens.
- * @description Supports strings, numbers, idents, operators.
- * @param expressionText - Raw expression text
- * @returns List of expression tokens
- * @throws {Deno.errors.InvalidData} When tokenization fails
- */
- static tokenize(expressionText: string): Types.ExprToken[] {
- const exprTokens: Types.ExprToken[] = []
- let cursorIndex = 0
- while (cursorIndex < expressionText.length) {
- const currentChar = expressionText[cursorIndex] ?? ''
- if (Tokenizer.isWhitespace(currentChar)) {
- cursorIndex++
- continue
- }
- const twoCharOp = expressionText.slice(cursorIndex, cursorIndex + 2)
- const threeCharOp = expressionText.slice(cursorIndex, cursorIndex + 3)
- if (threeCharOp === '===') {
- exprTokens.push({ kind: 'op', value: '===' })
- cursorIndex += 3
- continue
- }
- if (threeCharOp === '!==') {
- exprTokens.push({ kind: 'op', value: '!==' })
- cursorIndex += 3
- continue
- }
- if (
- twoCharOp === '&&' ||
- twoCharOp === '||' ||
- twoCharOp === '??' ||
- twoCharOp === '>=' ||
- twoCharOp === '<=' ||
- twoCharOp === '==' ||
- twoCharOp === '!='
- ) {
- exprTokens.push({ kind: 'op', value: twoCharOp })
- cursorIndex += 2
- continue
- }
- if (twoCharOp === '?.') {
- exprTokens.push({ kind: 'op', value: '?.' })
- cursorIndex += 2
- continue
- }
- if (
- currentChar === '(' ||
- currentChar === ')' ||
- currentChar === '?' ||
- currentChar === ':' ||
- currentChar === '.' ||
- currentChar === '!' ||
- currentChar === '+' ||
- currentChar === '-' ||
- currentChar === '*' ||
- currentChar === '/' ||
- currentChar === '%' ||
- currentChar === '>' ||
- currentChar === '<'
- ) {
- exprTokens.push({ kind: 'op', value: currentChar })
- cursorIndex++
- continue
- }
- if (currentChar === "'" || currentChar === '"') {
- const quoteChar = currentChar
- cursorIndex++
- let stringValue = ''
- let isClosed = false
- while (cursorIndex < expressionText.length) {
- const currentStringChar = expressionText[cursorIndex] ?? ''
- if (currentStringChar === '\\') {
- const escapeCode = expressionText[cursorIndex + 1] ?? ''
- if (escapeCode === 'n') {
- stringValue += '\n'
- } else if (escapeCode === 't') {
- stringValue += '\t'
- } else if (escapeCode === 'r') {
- stringValue += '\r'
- } else {
- stringValue += escapeCode
- }
- cursorIndex += 2
- continue
- }
- if (currentStringChar === quoteChar) {
- cursorIndex++
- isClosed = true
- break
- }
- stringValue += currentStringChar
- cursorIndex++
- }
- if (!isClosed) {
- throw new Deno.errors.InvalidData('Unterminated string literal in DVE expression')
- }
- exprTokens.push({ kind: 'string', value: stringValue })
- continue
- }
- if (Tokenizer.isDigitChar(currentChar)) {
- let endIndex = cursorIndex
- while (
- endIndex < expressionText.length &&
- Tokenizer.isDigitChar(expressionText[endIndex] ?? '')
- ) {
- endIndex++
- }
- if (expressionText[endIndex] === '.') {
- endIndex++
- while (
- endIndex < expressionText.length &&
- Tokenizer.isDigitChar(expressionText[endIndex] ?? '')
- ) {
- endIndex++
- }
- }
- const expChar = expressionText[endIndex]
- if (expChar === 'e' || expChar === 'E') {
- endIndex++
- const signChar = expressionText[endIndex]
- if (signChar === '+' || signChar === '-') {
- endIndex++
- }
- while (
- endIndex < expressionText.length &&
- Tokenizer.isDigitChar(expressionText[endIndex] ?? '')
- ) {
- endIndex++
- }
- }
- const numberText = expressionText.slice(cursorIndex, endIndex)
- exprTokens.push({ kind: 'number', value: Number(numberText) })
- cursorIndex = endIndex
- continue
- }
- if (Tokenizer.isIdentStart(currentChar)) {
- let endIndex = cursorIndex + 1
- while (
- endIndex < expressionText.length &&
- Tokenizer.isIdentifierChar(expressionText[endIndex] ?? '')
- ) {
- endIndex++
- }
- const identifierName = expressionText.slice(cursorIndex, endIndex)
- exprTokens.push({ kind: 'ident', value: identifierName })
- cursorIndex = endIndex
- continue
- }
- throw new Deno.errors.InvalidData(
- `Invalid DVE expression token near ${expressionText.slice(cursorIndex, cursorIndex + 10)}`
- )
- }
- return exprTokens
- }
-
- /**
- * Check if character is digit.
- * @description Tests for ASCII 0-9 characters.
- * @param inputChar - Single character to test
- * @returns True when digit
- */
- private static isDigitChar(inputChar: string): boolean {
- return inputChar >= '0' && inputChar <= '9'
- }
-
- /**
- * Check if character starts identifier.
- * @description Tests for letters, underscore, dollar, at sign.
- * @param inputChar - Single character to test
- * @returns True when valid identifier start
- */
- private static isIdentStart(inputChar: string): boolean {
- return (
- (inputChar >= 'a' && inputChar <= 'z') ||
- (inputChar >= 'A' && inputChar <= 'Z') ||
- inputChar === '_' ||
- inputChar === '$' ||
- inputChar === '@'
- )
- }
-
- /**
- * Check if character continues identifier.
- * @description Tests for identifier start chars or digits.
- * @param inputChar - Single character to test
- * @returns True when valid identifier char
- */
- private static isIdentifierChar(inputChar: string): boolean {
- return Tokenizer.isIdentStart(inputChar) || Tokenizer.isDigitChar(inputChar)
- }
-
- /**
- * Check if character is whitespace.
- * @description Tests for space, newline, tab, carriage return.
- * @param inputChar - Single character to test
- * @returns True when whitespace
- */
- private static isWhitespace(inputChar: string): boolean {
- return inputChar === ' ' || inputChar === '\n' || inputChar === '\t' || inputChar === '\r'
- }
-}
diff --git a/src/rendering/engine/Utils.ts b/src/rendering/engine/Utils.ts
deleted file mode 100644
index 47eb43a..0000000
--- a/src/rendering/engine/Utils.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import type * as Types from '@interfaces/index.ts'
-import * as Core from '@core/index.ts'
-
-/**
- * Engine helper utilities.
- * @description Provides path lookup and escaping helpers.
- */
-export class Utils {
- /**
- * Escape HTML special characters.
- * @description Converts characters to safe HTML entities.
- * @param rawText - Raw text to escape
- * @returns Escaped HTML-safe string
- */
- static escape(rawText: string): string {
- return Core.Handler.escapeHtml(rawText)
- }
-
- /**
- * Join root and relative path.
- * @description Normalizes slashes for filesystem paths.
- * @param rootDir - Root directory path
- * @param relativePath - Relative template path
- * @returns Joined path string
- */
- static join(rootDir: string, relativePath: string): string {
- const normalizedRootDir = rootDir.replace(/\/+$/, '')
- const normalizedRelativePath = relativePath.replace(/^\/+/, '').replace(/\\/g, '/')
- return `${normalizedRootDir}/${normalizedRelativePath}`
- }
-
- /**
- * Lookup dotted path value.
- * @description Traverses object by dot-separated segments using own properties.
- * @param dataObject - Root data object
- * @param dataPath - Dotted lookup path
- * @returns Resolved value or undefined
- */
- static lookup(dataObject: unknown, dataPath: string): unknown {
- let currentValue: unknown = dataObject
- let scanStart = 0
- const pathLength = dataPath.length
- while (scanStart <= pathLength) {
- let scanEnd = dataPath.indexOf('.', scanStart)
- if (scanEnd === -1) {
- scanEnd = pathLength
- }
- let segStart = scanStart
- let segEnd = scanEnd
- while (segStart < segEnd && dataPath.charCodeAt(segStart) === 32) {
- segStart++
- }
- while (segEnd > segStart && dataPath.charCodeAt(segEnd - 1) === 32) {
- segEnd--
- }
- if (segEnd > segStart) {
- const pathSegment = dataPath.slice(segStart, segEnd)
- currentValue = Utils.readOwn(currentValue, pathSegment)
- }
- scanStart = scanEnd + 1
- }
- return currentValue
- }
-
- /**
- * Read one own property safely.
- * @description Reads own data properties from object or string bases.
- * @param base - Value to read the property from
- * @param key - Property name to resolve
- * @returns Own property value or undefined when not safely readable
- */
- static readOwn(base: unknown, key: string): unknown {
- if (base === null || base === undefined) {
- return undefined
- }
- if (typeof base !== 'object' && typeof base !== 'string') {
- return undefined
- }
- if (!Object.hasOwn(base as Types.DataRecord, key)) {
- return undefined
- }
- return (base as Types.DataRecord)[key]
- }
-}
diff --git a/src/rendering/engine/index.ts b/src/rendering/engine/index.ts
deleted file mode 100644
index 9a77b11..0000000
--- a/src/rendering/engine/index.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-/** Re-export engine public API. */
-export * from '@rendering/engine/Eval.ts'
-export * from '@rendering/engine/Expression.ts'
-export * from '@rendering/engine/Parser.ts'
-export * from '@rendering/engine/Tokenizer.ts'
-export * from '@rendering/engine/Utils.ts'
diff --git a/src/rendering/index.ts b/src/rendering/index.ts
deleted file mode 100644
index 92dd38f..0000000
--- a/src/rendering/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-/** Re-export rendering public API. */
-export * from '@rendering/Discover.ts'
-export * from '@rendering/Engine.ts'
-export * from '@rendering/Watcher.ts'
diff --git a/tests/core/Rendering.test.ts b/tests/core/Rendering.test.ts
new file mode 100644
index 0000000..186333d
--- /dev/null
+++ b/tests/core/Rendering.test.ts
@@ -0,0 +1,64 @@
+import { assertEquals } from '@std/assert'
+import { fileURLToPath } from 'node:url'
+import * as Core from '@core/index.ts'
+
+const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(/[/\\]$/, '')
+
+Deno.test('Rendering defaults directory to ./views', () => {
+ const engine = new Core.Rendering({})
+ assertEquals(engine.directory, './views')
+})
+
+Deno.test('Rendering exposes the configured directory', () => {
+ const engine = new Core.Rendering({ directory: viewsDir })
+ assertEquals(engine.directory, viewsDir)
+})
+
+Deno.test('Rendering invalidate emits an invalidated event', async () => {
+ const events: string[] = []
+ const engine = new Core.Rendering({ directory: viewsDir }, (event) => events.push(event.kind))
+ await engine.render('hello', { name: 'A' }, {})
+ engine.invalidate('hello')
+ assertEquals(events.includes('view:invalidated'), true)
+})
+
+Deno.test('Rendering render caches compiled templates', async () => {
+ const engine = new Core.Rendering({ directory: viewsDir })
+ await engine.render('hello', { name: 'A' }, {})
+ const res = await engine.render('hello', { name: 'B' }, {})
+ assertEquals((await res.text()).includes('Hello B'), true)
+})
+
+Deno.test('Rendering render compiles and renders a template', async () => {
+ const engine = new Core.Rendering({ directory: viewsDir })
+ const res = await engine.render('hello', { name: 'World' }, {})
+ assertEquals(res.status, 200)
+ assertEquals(res.headers.get('content-type'), 'text/html; charset=utf-8')
+ assertEquals((await res.text()).includes('Hello World'), true)
+})
+
+Deno.test('Rendering render evaluates expressions', async () => {
+ const engine = new Core.Rendering({ directory: viewsDir })
+ const res = await engine.render('expr', { user: { name: 'Ann', isAdmin: true } }, {})
+ const html = await res.text()
+ assertEquals(html.includes('Hello Ann'), true)
+ assertEquals(html.includes('ADMIN'), true)
+ assertEquals(html.includes('Sum=7'), true)
+})
+
+Deno.test('Rendering render honors a custom status', async () => {
+ const engine = new Core.Rendering({ directory: viewsDir })
+ const res = await engine.render('hello', { name: 'X' }, { status: 201 })
+ assertEquals(res.status, 201)
+})
+
+Deno.test('Rendering render throws for a missing template', async () => {
+ const engine = new Core.Rendering({ directory: viewsDir })
+ let threw = false
+ try {
+ await engine.render('does-not-exist', {}, {})
+ } catch {
+ threw = true
+ }
+ assertEquals(threw, true)
+})
diff --git a/tests/core/View.test.ts b/tests/core/View.test.ts
new file mode 100644
index 0000000..78784c3
--- /dev/null
+++ b/tests/core/View.test.ts
@@ -0,0 +1,19 @@
+import { assertEquals } from '@std/assert'
+import { fileURLToPath } from 'node:url'
+import * as Core from '@core/index.ts'
+
+const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(/[/\\]$/, '')
+
+Deno.test('View watch returns a callable disposer for a real directory', () => {
+ const engine = new Core.Rendering({ directory: viewsDir })
+ const stop = Core.View.watch(engine)
+ assertEquals(typeof stop, 'function')
+ stop()
+})
+
+Deno.test('View watch returns a noop disposer for a missing directory', () => {
+ const engine = new Core.Rendering({ directory: './does-not-exist-views-xyz' })
+ const stop = Core.View.watch(engine)
+ assertEquals(typeof stop, 'function')
+ stop()
+})
diff --git a/tests/rendering/Discover.test.ts b/tests/rendering/Discover.test.ts
deleted file mode 100644
index 241be60..0000000
--- a/tests/rendering/Discover.test.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { assertEquals } from '@std/assert'
-import { fileURLToPath } from 'node:url'
-import * as Rendering from '@rendering/index.ts'
-
-Deno.test('Discover#discoverPaths finds .dve files in directory', async () => {
- const fixtureDir = fileURLToPath(import.meta.resolve('@tests/fixtures/static/')).replace(
- /[\\/]$/,
- ''
- )
- const paths = await Rendering.Discover.discoverPaths(fixtureDir)
- assertEquals(paths instanceof Set, true)
-})
-
-Deno.test('Discover#discoverPaths finds .dve files in views directory', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const paths = await Rendering.Discover.discoverPaths(viewsDir)
- assertEquals(paths.size > 0, true)
- let hasHello = false
- for (const p of paths) {
- if (p.endsWith('hello.dve')) {
- hasHello = true
- }
- }
- assertEquals(hasHello, true)
-})
-
-Deno.test('Discover#discoverPaths returns Set type', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const paths = await Rendering.Discover.discoverPaths(viewsDir)
- assertEquals(paths instanceof Set, true)
-})
-
-Deno.test('Discover#discoverPaths returns empty set for non-existent dir', async () => {
- const paths = await Rendering.Discover.discoverPaths('/nonexistent-dir-' + Date.now())
- assertEquals(paths.size, 0)
-})
diff --git a/tests/rendering/Engine.test.ts b/tests/rendering/Engine.test.ts
deleted file mode 100644
index 3b126f2..0000000
--- a/tests/rendering/Engine.test.ts
+++ /dev/null
@@ -1,623 +0,0 @@
-import type * as Types from '@interfaces/index.ts'
-import { assertEquals, assertRejects } from '@std/assert'
-import { fileURLToPath } from 'node:url'
-import * as Rendering from '@rendering/index.ts'
-
-Deno.test('Engine#invalidateFile clears cache so template is reloaded', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
- const first = await engine.render('hello.dve', { name: 'A' })
- assertEquals(first.trim(), 'Hello A.')
- const absPath = `${viewsDir}/hello.dve`
- engine.invalidateFile(absPath)
- const second = await engine.render('hello.dve', { name: 'B' })
- assertEquals(second.trim(), 'Hello B.')
-})
-
-Deno.test('Engine#notifyRefresh emits a view:refreshed event with the changed paths', () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const events: Types.EventBase[] = []
- const engine = new Rendering.Engine({ viewsDir, emit: (event) => events.push(event) })
- engine.notifyRefresh(['/v.dve'])
- assertEquals(events.length, 1)
- const refreshed = events[0] as {
- type: string
- kind: string
- metadata: { paths: readonly string[] }
- }
- assertEquals(refreshed.kind, 'view:refreshed')
- assertEquals(refreshed.type, 'internal')
- assertEquals(refreshed.metadata.paths, ['/v.dve'])
-})
-
-Deno.test('Engine#refreshPaths resets discovered paths', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
- await engine.render('hello.dve', { name: 'X' })
- engine.refreshPaths()
- const html = await engine.render('hello.dve', { name: 'Y' })
- assertEquals(html.trim(), 'Hello Y.')
-})
-
-Deno.test('Engine#render appends .dve when omitted', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
- const html = await engine.render('hello', { name: 'NoExt' })
- assertEquals(html.trim(), 'Hello NoExt.')
-})
-
-Deno.test('Engine#render caches compiled template', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
- const first = await engine.render('hello.dve', { name: 'A' })
- const second = await engine.render('hello.dve', { name: 'B' })
- assertEquals(first.trim(), 'Hello A.')
- assertEquals(second.trim(), 'Hello B.')
-})
-
-Deno.test('Engine#render completes a healthy template within the default budget', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
- const html = await engine.render('each.dve', { items: [1, 2, 3] })
- assertEquals(html.trim(), '1,2,3,')
-})
-
-Deno.test('Engine#render does not emit view:error for a successful render', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const events: Types.EventBase[] = []
- const engine = new Rendering.Engine({ viewsDir, emit: (event) => events.push(event) })
- await engine.render('hello.dve', { name: 'OK' })
- assertEquals(events.filter((event) => event.kind === 'view:error').length, 0)
-})
-
-Deno.test('Engine#render each does not resolve item prototype members', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
- const html = await engine.render('each-proto.dve', { items: [{ x: 1 }] })
- assertEquals(html.trim(), '[]')
-})
-
-Deno.test('Engine#render each exposes @index/@first/@last/@length', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
- const html = await engine.render('each-meta.dve', { items: ['a', 'b', 'c'] })
- assertEquals(html.trim(), '(0/3 F-=a);(1/3 --=b);(2/3 -L=c);')
-})
-
-Deno.test('Engine#render each exposes parent-scope variables inside the loop', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
- const html = await engine.render('each-parent.dve', { title: 'T', items: ['a', 'b'] })
- assertEquals(html.trim(), 'T:a;T:b;')
-})
-
-Deno.test('Engine#render each renders all items', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
- const html = await engine.render('each.dve', { items: [1, 2, 3] })
- assertEquals(html.trim(), '1,2,3,')
-})
-
-Deno.test('Engine#render each with empty array renders nothing', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
- const html = await engine.render('each.dve', { items: [] })
- assertEquals(html.trim(), '')
-})
-
-Deno.test('Engine#render each with non-array data renders nothing', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
- const html = await engine.render('each-nonarray.dve', { items: 'not-an-array' })
- assertEquals(html.trim(), '')
-})
-
-Deno.test('Engine#render each with null data renders nothing', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
- const html = await engine.render('each-nonarray.dve', { items: null })
- assertEquals(html.trim(), '')
-})
-
-Deno.test('Engine#render emits a single view:error for a failure inside an included template', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const events: Types.EventBase[] = []
- const engine = new Rendering.Engine({ viewsDir, emit: (event) => events.push(event) })
- await assertRejects(() => engine.render('include-broken.dve', { payload: () => 'x' }), Error)
- const viewErrors = events.filter((event) => event.kind === 'view:error')
- assertEquals(viewErrors.length, 1)
- const meta = (viewErrors[0] as { metadata: { path: string } }).metadata
- assertEquals(meta.path, 'include-broken.dve')
-})
-
-Deno.test('Engine#render emits view:compiled only once across cached renders', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const events: Types.EventBase[] = []
- const engine = new Rendering.Engine({ viewsDir, emit: (event) => events.push(event) })
- await engine.render('hello.dve', { name: 'A' })
- await engine.render('hello.dve', { name: 'B' })
- assertEquals(events.filter((event) => event.kind === 'view:compiled').length, 1)
- assertEquals(events.filter((event) => event.kind === 'view:rendered').length, 2)
-})
-
-Deno.test('Engine#render emits view:compiled then view:rendered with timing', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const events: Types.EventBase[] = []
- const engine = new Rendering.Engine({ viewsDir, emit: (event) => events.push(event) })
- await engine.render('hello.dve', { name: 'X' })
- assertEquals(events.map((event) => event.kind), ['view:compiled', 'view:rendered'])
- assertEquals(events.every((event) => event.type === 'internal'), true)
- const rendered = events.find((event) => event.kind === 'view:rendered') as
- | { metadata: { path: string; durationMs: number } }
- | undefined
- assertEquals(rendered?.metadata.path, 'hello.dve')
- assertEquals(typeof rendered?.metadata.durationMs, 'number')
- assertEquals((rendered?.metadata.durationMs ?? -1) >= 0, true)
-})
-
-Deno.test('Engine#render emits view:error when expression evaluation fails at render time', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const events: Types.EventBase[] = []
- const engine = new Rendering.Engine({ viewsDir, emit: (event) => events.push(event) })
- await assertRejects(() => engine.render('attack-call.dve', { payload: () => 'x' }), Error)
- const viewErrors = events.filter((event) => event.kind === 'view:error')
- assertEquals(viewErrors.length, 1)
- const meta = (viewErrors[0] as { metadata: { path: string; error: Error } }).metadata
- assertEquals(meta.path, 'attack-call.dve')
-})
-
-Deno.test('Engine#render emits view:error with the template path when compilation fails', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const events: Types.EventBase[] = []
- const engine = new Rendering.Engine({ viewsDir, emit: (event) => events.push(event) })
- await assertRejects(() => engine.render('attack-unclosed-block.dve', { ok: true }), Error)
- const viewErrors = events.filter((event) => event.kind === 'view:error')
- assertEquals(viewErrors.length, 1)
- const meta = (viewErrors[0] as { metadata: { path: string; error: Error } }).metadata
- assertEquals(meta.path, 'attack-unclosed-block.dve')
- assertEquals(meta.error instanceof Error, true)
-})
-
-Deno.test('Engine#render escapes variable by default', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
- const html = await engine.render('escape.dve', { value: ''
- const html = await engine.render('attack-raw.dve', { payload })
- assertEquals(html.trim(), payload)
-})
-
-Deno.test('Engine#render security: self-including template is stopped by include depth limit', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
- await assertRejects(
- () => engine.render('each-recurse.dve', {}),
- Deno.errors.InvalidData,
- 'include depth'
- )
-})
-
-Deno.test('Engine#render security: the constructor.constructor RCE gadget resolves to nothing', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
- const html = await engine.render('attack-proto-chain.dve', { x: {} })
- assertEquals(html.trim(), '[|]')
-})
-
-Deno.test('Engine#render security: unterminated string literal is rejected', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
- await assertRejects(
- () => engine.render('attack-unclosed-string.dve', {}),
- Error,
- 'Unterminated string literal in DVE expression'
- )
-})
-
-Deno.test('Engine#render supports JS-like expressions in {{ ... }}', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
-
- const htmlGuest = await engine.render('expr.dve', {})
- assertEquals(htmlGuest.trim(), 'Hello Guest.\nUSER\nSum=7')
-
- const htmlAdmin = await engine.render('expr.dve', { user: { name: 'Nea', isAdmin: true } })
- assertEquals(htmlAdmin.trim(), 'Hello Nea.\nADMIN\nSum=7')
-})
-
-Deno.test('Engine#render throws when template not found', async () => {
- const engine = new Rendering.Engine({ viewsDir: '/nonexistent-' + Date.now() })
- await assertRejects(
- () => engine.render('missing.dve', {}),
- Error,
- 'Template "missing.dve" not found in views directory'
- )
-})
-
-Deno.test('Engine#render variable with undefined value renders empty', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
- const html = await engine.render('hello.dve', {})
- assertEquals(html.trim(), 'Hello .')
-})
-
-Deno.test('Engine#render with backslash in path normalizes', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
- const html = await engine.render('hello.dve', { name: 'Backslash' })
- assertEquals(html.trim(), 'Hello Backslash.')
-})
-
-Deno.test('Engine#render with empty data object', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
- const html = await engine.render('hello.dve', {})
- assertEquals(html.trim(), 'Hello .')
-})
-
-Deno.test('Engine#render with null variable value renders empty', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
- const html = await engine.render('hello.dve', { name: null })
- assertEquals(html.trim(), 'Hello .')
-})
-
-Deno.test({
- name: 'Engine#streamRender produces correct output',
- fn: async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
- const stream = await engine.streamRender('hello.dve', { name: 'Test' })
- const reader = stream.getReader()
- let result = ''
- const decoder = new TextDecoder()
- while (true) {
- const { done, value } = await reader.read()
- if (done) {
- break
- }
- result += decoder.decode(value, { stream: true })
- }
- assertEquals(result.trim(), 'Hello Test.')
- },
- sanitizeOps: false
-})
-
-Deno.test({
- name: 'Engine#streamRender rejects before streaming when template is missing',
- fn: async () => {
- const engine = new Rendering.Engine({ viewsDir: '/nonexistent-' + Date.now() })
- await assertRejects(
- () => engine.streamRender('missing.dve', {}),
- Deno.errors.NotFound
- )
- },
- sanitizeOps: false
-})
-
-Deno.test('Engine#streamRender returns ReadableStream response', async () => {
- const viewsDir = fileURLToPath(import.meta.resolve('@tests/fixtures/views/')).replace(
- /[\\/]$/,
- ''
- )
- const engine = new Rendering.Engine({ viewsDir })
- const stream = await engine.streamRender('hello.dve', { name: 'Stream' })
- assertEquals(stream instanceof ReadableStream, true)
- await stream.cancel()
-})
-
-Deno.test('Engine#viewsDir returns configured directory', () => {
- const engine = new Rendering.Engine({ viewsDir: '/tmp/views' })
- assertEquals(engine.viewsDir, '/tmp/views')
-})
-
-Deno.test('Watcher#watch skips a non-existent views directory without throwing', () => {
- const engine = new Rendering.Engine({ viewsDir: './does-not-exist-views-dir-xyz' })
- Rendering.Watcher.watch(engine)
- assertEquals(true, true)
-})
diff --git a/tests/rendering/Watcher.test.ts b/tests/rendering/Watcher.test.ts
deleted file mode 100644
index 0ae8d84..0000000
--- a/tests/rendering/Watcher.test.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import type * as Types from '@interfaces/index.ts'
-import { assertEquals } from '@std/assert'
-import * as Rendering from '@rendering/index.ts'
-
-const writeGranted = (await Deno.permissions.query({ name: 'write' })).state === 'granted'
-
-function createFakeEngine(viewsDir: string): {
- engine: Types.WatchableEngine
- invalidated: string[]
- refreshCount: number
- refreshedBatches: string[][]
-} {
- const invalidated: string[] = []
- const refreshedBatches: string[][] = []
- let refreshCount = 0
- const engine: Types.WatchableEngine = {
- viewsDir,
- invalidateFile(absPath: string): void {
- invalidated.push(absPath)
- },
- refreshPaths(): void {
- refreshCount++
- },
- notifyRefresh(paths: readonly string[]): void {
- refreshedBatches.push([...paths])
- }
- }
- return {
- engine,
- invalidated,
- get refreshCount(): number {
- return refreshCount
- },
- refreshedBatches
- }
-}
-
-function delay(ms: number): Promise {
- return new Promise((resolve) => setTimeout(resolve, ms))
-}
-
-async function makeViewsDir(): Promise {
- const dir = await Deno.makeTempDir({ prefix: 'deserve-views-' })
- return await Deno.realPath(dir)
-}
-
-Deno.test({
- name: 'Watcher#watch ignores non-.dve files',
- ignore: !writeGranted,
- fn: async () => {
- const dir = await makeViewsDir()
- const recorder = createFakeEngine(dir)
- const stop = Rendering.Watcher.watch(recorder.engine)
- try {
- await delay(50)
- await Deno.writeTextFile(`${dir}/notes.txt`, 'not a template')
- await delay(400)
- assertEquals(recorder.invalidated.length, 0)
- assertEquals(recorder.refreshCount, 0)
- } finally {
- stop()
- await Deno.remove(dir, { recursive: true })
- }
- }
-})
-
-Deno.test({
- name: 'Watcher#watch invalidates and refreshes when a .dve template changes',
- ignore: !writeGranted,
- fn: async () => {
- const dir = await makeViewsDir()
- await Deno.writeTextFile(`${dir}/page.dve`, 'Hello {{ name }}.')
- const recorder = createFakeEngine(dir)
- const stop = Rendering.Watcher.watch(recorder.engine)
- try {
- await delay(50)
- await Deno.writeTextFile(`${dir}/page.dve`, 'Hello {{ name }}!')
- await delay(400)
- assertEquals(recorder.invalidated.length >= 1, true)
- assertEquals(recorder.invalidated.some((p) => p.endsWith('page.dve')), true)
- assertEquals(recorder.refreshCount >= 1, true)
- assertEquals(recorder.refreshedBatches.length >= 1, true)
- } finally {
- stop()
- await Deno.remove(dir, { recursive: true })
- }
- }
-})
-
-Deno.test('Watcher#watch returns a no-op stop handle for a non-existent directory', () => {
- const recorder = createFakeEngine('./does-not-exist-views-dir-' + Date.now())
- const stop = Rendering.Watcher.watch(recorder.engine)
- assertEquals(typeof stop, 'function')
- stop()
- assertEquals(recorder.invalidated.length, 0)
-})
-
-Deno.test({
- name: 'Watcher#watch stop handle releases the watcher and halts further invalidation',
- ignore: !writeGranted,
- fn: async () => {
- const dir = await makeViewsDir()
- const recorder = createFakeEngine(dir)
- const stop = Rendering.Watcher.watch(recorder.engine)
- await delay(50)
- stop()
- await delay(50)
- await Deno.writeTextFile(`${dir}/late.dve`, 'late')
- await delay(400)
- try {
- assertEquals(recorder.invalidated.length, 0)
- } finally {
- await Deno.remove(dir, { recursive: true })
- }
- }
-})
diff --git a/tests/rendering/engine/Eval.test.ts b/tests/rendering/engine/Eval.test.ts
deleted file mode 100644
index d9262a6..0000000
--- a/tests/rendering/engine/Eval.test.ts
+++ /dev/null
@@ -1,275 +0,0 @@
-import { assertEquals, assertThrows } from '@std/assert'
-import { Eval } from '@rendering/engine/Eval.ts'
-
-Deno.test('Eval#evaluate NaN arithmetic', () => {
- const result = Eval.evaluate('a / b', { a: 0, b: 0 })
- assertEquals(Number.isNaN(result as number), true)
-})
-
-Deno.test('Eval#evaluate arithmetic addition', () => {
- assertEquals(Eval.evaluate('a + b', { a: 3, b: 4 }), 7)
-})
-
-Deno.test('Eval#evaluate arithmetic division', () => {
- assertEquals(Eval.evaluate('a / b', { a: 10, b: 2 }), 5)
-})
-
-Deno.test('Eval#evaluate arithmetic modulo', () => {
- assertEquals(Eval.evaluate('a % b', { a: 10, b: 3 }), 1)
-})
-
-Deno.test('Eval#evaluate arithmetic multiplication', () => {
- assertEquals(Eval.evaluate('a * b', { a: 3, b: 4 }), 12)
-})
-
-Deno.test('Eval#evaluate arithmetic subtraction', () => {
- assertEquals(Eval.evaluate('a - b', { a: 10, b: 3 }), 7)
-})
-
-Deno.test('Eval#evaluate chained nullish coalescing', () => {
- assertEquals(Eval.evaluate('a ?? b ?? c', { c: 'found' }), 'found')
-})
-
-Deno.test('Eval#evaluate complex nested expression', () => {
- assertEquals(Eval.evaluate('a > 0 ? a * 2 : -a', { a: 5 }), 10)
- assertEquals(Eval.evaluate('a > 0 ? a * 2 : -a', { a: -3 }), 3)
-})
-
-Deno.test('Eval#evaluate decimal number literal', () => {
- assertEquals(Eval.evaluate('3.14', {}), 3.14)
-})
-
-Deno.test('Eval#evaluate deep dotted path', () => {
- assertEquals(Eval.evaluate('a.b.c', { a: { b: { c: 42 } } }), 42)
-})
-
-Deno.test('Eval#evaluate division by zero returns Infinity', () => {
- assertEquals(Eval.evaluate('a / b', { a: 1, b: 0 }), Infinity)
-})
-
-Deno.test('Eval#evaluate does not resolve inherited __proto__ identifier', () => {
- assertEquals(Eval.evaluate('__proto__', {}), undefined)
-})
-
-Deno.test('Eval#evaluate does not resolve inherited constructor identifier', () => {
- assertEquals(Eval.evaluate('constructor', {}), undefined)
-})
-
-Deno.test('Eval#evaluate does not resolve inherited member access', () => {
- assertEquals(Eval.evaluate('a.constructor', { a: { x: 1 } }), undefined)
- assertEquals(Eval.evaluate('a.toString', { a: {} }), undefined)
-})
-
-Deno.test('Eval#evaluate does not resolve inherited toString identifier', () => {
- assertEquals(Eval.evaluate('toString', {}), undefined)
-})
-
-Deno.test('Eval#evaluate does not resolve string prototype __proto__', () => {
- assertEquals(Eval.evaluate('a.__proto__', { a: 'abc' }), undefined)
-})
-
-Deno.test('Eval#evaluate does not resolve string prototype constructor', () => {
- assertEquals(Eval.evaluate('a.constructor', { a: 'abc' }), undefined)
-})
-
-Deno.test('Eval#evaluate does not resolve string prototype method', () => {
- assertEquals(Eval.evaluate('a.toUpperCase', { a: 'abc' }), undefined)
-})
-
-Deno.test('Eval#evaluate dotted path fast path', () => {
- assertEquals(Eval.evaluate('user.name', { user: { name: 'Bob' } }), 'Bob')
-})
-
-Deno.test('Eval#evaluate dotted path on missing key returns undefined', () => {
- assertEquals(Eval.evaluate('a.b.c', { a: {} }), undefined)
-})
-
-Deno.test('Eval#evaluate dotted path on null returns undefined', () => {
- assertEquals(Eval.evaluate('a.b', { a: null }), undefined)
-})
-
-Deno.test('Eval#evaluate empty expression returns undefined', () => {
- assertEquals(Eval.evaluate('', {}), undefined)
-})
-
-Deno.test('Eval#evaluate keyword literal name is overridden by an own scope property', () => {
- assertEquals(Eval.evaluate('true', { true: 'shadowed' }), 'shadowed')
-})
-
-Deno.test('Eval#evaluate keyword literals resolve to their values inside expressions', () => {
- assertEquals(Eval.evaluate('a == true', { a: true }), true)
- assertEquals(Eval.evaluate('a ? true : false', { a: 1 }), true)
- assertEquals(Eval.evaluate('a ? true : false', { a: 0 }), false)
- assertEquals(Eval.evaluate('true && 5', {}), 5)
- assertEquals(Eval.evaluate('a ?? null', { a: undefined }), null)
-})
-
-Deno.test('Eval#evaluate literal false via expression', () => {
- assertEquals(Eval.evaluate('!true', {}), false)
-})
-
-Deno.test('Eval#evaluate literal true via expression', () => {
- assertEquals(Eval.evaluate('!false', {}), true)
-})
-
-Deno.test('Eval#evaluate logical AND short-circuits', () => {
- assertEquals(Eval.evaluate('a && b', { a: false, b: 42 }), false)
- assertEquals(Eval.evaluate('a && b', { a: true, b: 42 }), 42)
-})
-
-Deno.test('Eval#evaluate logical OR short-circuits', () => {
- assertEquals(Eval.evaluate('a || b', { a: 'yes', b: 'no' }), 'yes')
- assertEquals(Eval.evaluate('a || b', { a: '', b: 'fallback' }), 'fallback')
-})
-
-Deno.test('Eval#evaluate loose equality', () => {
- assertEquals(Eval.evaluate('a == b', { a: 1, b: '1' }), true)
-})
-
-Deno.test('Eval#evaluate loose inequality', () => {
- assertEquals(Eval.evaluate('a != b', { a: 1, b: 2 }), true)
-})
-
-Deno.test('Eval#evaluate member access on non-object returns undefined', () => {
- assertEquals(Eval.evaluate('a.b', { a: 42 }), undefined)
-})
-
-Deno.test('Eval#evaluate member access on null returns undefined', () => {
- assertEquals(Eval.evaluate('a.b', { a: null }), undefined)
-})
-
-Deno.test('Eval#evaluate member access via expression path', () => {
- assertEquals(Eval.evaluate('a.b + 1', { a: { b: 5 } }), 6)
-})
-
-Deno.test('Eval#evaluate nullish coalescing', () => {
- assertEquals(Eval.evaluate('a ?? b', { a: null, b: 'default' }), 'default')
- assertEquals(Eval.evaluate('a ?? b', { a: undefined, b: 'default' }), 'default')
- assertEquals(Eval.evaluate('a ?? b', { a: 0, b: 'default' }), 0)
- assertEquals(Eval.evaluate('a ?? b', { a: '', b: 'default' }), '')
-})
-
-Deno.test('Eval#evaluate number literal', () => {
- assertEquals(Eval.evaluate('42', {}), 42)
-})
-
-Deno.test('Eval#evaluate optional chaining returns undefined for null object', () => {
- assertEquals(Eval.evaluate('a?.b', { a: null }), undefined)
-})
-
-Deno.test('Eval#evaluate parenthesized expression', () => {
- assertEquals(Eval.evaluate('(a + b) * c', { a: 1, b: 2, c: 3 }), 9)
-})
-
-Deno.test('Eval#evaluate reads own length of a string primitive', () => {
- assertEquals(Eval.evaluate('a.length', { a: 'hello' }), 5)
-})
-
-Deno.test('Eval#evaluate rejects computed member access', () => {
- assertThrows(() => Eval.evaluate('arr[0]', { arr: [1, 2, 3] }), Deno.errors.InvalidData)
- assertThrows(() => Eval.evaluate('a["b"]', { a: { b: 1 } }), Deno.errors.InvalidData)
-})
-
-Deno.test('Eval#evaluate rejects function call expressions', () => {
- assertThrows(() => Eval.evaluate('fn()', { fn: () => 9 }), Deno.errors.InvalidData)
- assertThrows(() => Eval.evaluate('a.b()', { a: { b: () => 9 } }), Deno.errors.InvalidData)
-})
-
-Deno.test('Eval#evaluate relational operators', () => {
- assertEquals(Eval.evaluate('a > b', { a: 5, b: 3 }), true)
- assertEquals(Eval.evaluate('a < b', { a: 3, b: 5 }), true)
- assertEquals(Eval.evaluate('a >= b', { a: 5, b: 5 }), true)
- assertEquals(Eval.evaluate('a <= b', { a: 3, b: 5 }), true)
-})
-
-Deno.test('Eval#evaluate resolves an own data property shadowing a builtin name', () => {
- assertEquals(Eval.evaluate('a.hasOwnProperty', { a: { hasOwnProperty: 'mine' } }), 'mine')
-})
-
-Deno.test('Eval#evaluate scientific notation number literals', () => {
- assertEquals(Eval.evaluate('1e3', {}), 1000)
- assertEquals(Eval.evaluate('2E2', {}), 200)
- assertEquals(Eval.evaluate('1.5e2', {}), 150)
- assertEquals(Eval.evaluate('5e-1', {}), 0.5)
-})
-
-Deno.test('Eval#evaluate scientific notation participates in arithmetic', () => {
- assertEquals(Eval.evaluate('1e3 + 1', {}), 1001)
-})
-
-Deno.test('Eval#evaluate simple identifier from scope', () => {
- assertEquals(Eval.evaluate('name', { name: 'Alice' }), 'Alice')
-})
-
-Deno.test('Eval#evaluate strict equality', () => {
- assertEquals(Eval.evaluate('a === b', { a: 1, b: 1 }), true)
- assertEquals(Eval.evaluate('a === b', { a: 1, b: '1' }), false)
-})
-
-Deno.test('Eval#evaluate strict inequality', () => {
- assertEquals(Eval.evaluate('a !== b', { a: 1, b: 2 }), true)
- assertEquals(Eval.evaluate('a !== b', { a: 1, b: 1 }), false)
-})
-
-Deno.test('Eval#evaluate string concatenation', () => {
- assertEquals(Eval.evaluate('a + b', { a: 'hello ', b: 'world' }), 'hello world')
-})
-
-Deno.test('Eval#evaluate string literal', () => {
- assertEquals(Eval.evaluate('"hello"', {}), 'hello')
-})
-
-Deno.test('Eval#evaluate ternary operator', () => {
- assertEquals(Eval.evaluate('a ? "yes" : "no"', { a: true }), 'yes')
- assertEquals(Eval.evaluate('a ? "yes" : "no"', { a: false }), 'no')
-})
-
-Deno.test('Eval#evaluate true/false/null in complex expression', () => {
- assertEquals(Eval.evaluate('1 === 1', {}), true)
- assertEquals(Eval.evaluate('1 === 2', {}), false)
- assertEquals(Eval.evaluate('a ?? "fallback"', {}), 'fallback')
-})
-
-Deno.test('Eval#evaluate true/false/null/undefined are simple paths', () => {
- assertEquals(Eval.evaluate('true', {}), undefined)
- assertEquals(Eval.evaluate('false', {}), undefined)
- assertEquals(Eval.evaluate('null', {}), undefined)
- assertEquals(Eval.evaluate('undefined', {}), undefined)
-})
-
-Deno.test('Eval#evaluate unary minus negates', () => {
- assertEquals(Eval.evaluate('-a', { a: 5 }), -5)
- assertEquals(Eval.evaluate('-a', { a: '3' }), -3)
-})
-
-Deno.test('Eval#evaluate unary minus on non-numeric', () => {
- assertEquals(Eval.evaluate('-a', { a: true }), -1)
-})
-
-Deno.test('Eval#evaluate unary not', () => {
- assertEquals(Eval.evaluate('!a', { a: true }), false)
- assertEquals(Eval.evaluate('!a', { a: false }), true)
- assertEquals(Eval.evaluate('!a', { a: 0 }), true)
-})
-
-Deno.test('Eval#evaluate unary plus converts to number', () => {
- assertEquals(Eval.evaluate('+a', { a: '5' }), 5)
- assertEquals(Eval.evaluate('+a', { a: 3 }), 3)
-})
-
-Deno.test('Eval#evaluate unary plus on null', () => {
- assertEquals(Eval.evaluate('+a', { a: null }), 0)
-})
-
-Deno.test('Eval#evaluate unary plus on undefined', () => {
- const result = Eval.evaluate('+a', {})
- assertEquals(Number.isNaN(result as number), true)
-})
-
-Deno.test('Eval#evaluate uses string length in array-style condition', () => {
- assertEquals(Eval.evaluate('a.length > 0', { a: 'x' }), true)
-})
-
-Deno.test('Eval#evaluate whitespace-only returns undefined', () => {
- assertEquals(Eval.evaluate(' ', {}), undefined)
-})
diff --git a/tests/rendering/engine/Expression.test.ts b/tests/rendering/engine/Expression.test.ts
deleted file mode 100644
index bd84bc0..0000000
--- a/tests/rendering/engine/Expression.test.ts
+++ /dev/null
@@ -1,168 +0,0 @@
-import { assertEquals, assertThrows } from '@std/assert'
-import { Tokenizer } from '@rendering/engine/Tokenizer.ts'
-import { Expression } from '@rendering/engine/Expression.ts'
-
-function parseExpr(expr: string) {
- const tokens = Tokenizer.tokenize(expr)
- const parser = new Expression(tokens)
- const node = parser.parse()
- parser.assertEnd()
- return node
-}
-
-Deno.test('Expression#assertEnd throws on unconsumed tokens', () => {
- const tokens = Tokenizer.tokenize('a b')
- const parser = new Expression(tokens)
- parser.parse()
- assertThrows(() => parser.assertEnd(), Error, 'Unexpected token')
-})
-
-Deno.test('Expression#parse binary addition', () => {
- const node = parseExpr('a + b')
- assertEquals(node.type, 'binary')
-})
-
-Deno.test('Expression#parse binary multiplication', () => {
- const node = parseExpr('a * b')
- assertEquals(node.type, 'binary')
-})
-
-Deno.test('Expression#parse chained member access', () => {
- const node = parseExpr('a.b.c.d')
- assertEquals(node.type, 'member')
-})
-
-Deno.test('Expression#parse empty tokens throws', () => {
- assertThrows(
- () => {
- const parser = new Expression([])
- parser.parse()
- },
- Error,
- 'Unexpected end'
- )
-})
-
-Deno.test('Expression#parse equality', () => {
- const node = parseExpr('a === b')
- assertEquals(node.type, 'binary')
-})
-
-Deno.test('Expression#parse expected identifier after ?. throws', () => {
- assertThrows(() => parseExpr('a?. 5'), Error, 'Expected identifier after "?."')
-})
-
-Deno.test('Expression#parse expected identifier after dot throws', () => {
- assertThrows(() => parseExpr('a. 5'), Error, 'Expected identifier after "."')
-})
-
-Deno.test('Expression#parse identifier', () => {
- const node = parseExpr('foo')
- assertEquals(node.type, 'ident')
-})
-
-Deno.test('Expression#parse inequality operators', () => {
- for (const op of ['!==', '!=', '==']) {
- const node = parseExpr(`a ${op} b`)
- assertEquals(node.type, 'binary')
- assertEquals((node as { op: string }).op, op)
- }
-})
-
-Deno.test('Expression#parse invalid primary token throws', () => {
- assertThrows(() => parseExpr(')'), Error, 'Invalid primary')
-})
-
-Deno.test('Expression#parse left-associative addition', () => {
- const node = parseExpr('a + b + c')
- assertEquals(node.type, 'binary')
- assertEquals((node as { left: { type: string } }).left.type, 'binary')
-})
-
-Deno.test('Expression#parse logical AND and OR', () => {
- const node = parseExpr('a && b || c')
- assertEquals(node.type, 'binary')
-})
-
-Deno.test('Expression#parse member access', () => {
- const node = parseExpr('a.b.c')
- assertEquals(node.type, 'member')
-})
-
-Deno.test('Expression#parse missing closing paren throws', () => {
- assertThrows(() => parseExpr('(a + b'), Error, "Expected ')'")
-})
-
-Deno.test('Expression#parse modulo and division', () => {
- for (const op of ['/', '%']) {
- const node = parseExpr(`a ${op} b`)
- assertEquals(node.type, 'binary')
- }
-})
-
-Deno.test('Expression#parse nested ternary', () => {
- const node = parseExpr('a ? b ? 1 : 2 : 3')
- assertEquals(node.type, 'ternary')
-})
-
-Deno.test('Expression#parse nullish coalescing', () => {
- const node = parseExpr('a ?? b')
- assertEquals(node.type, 'binary')
-})
-
-Deno.test('Expression#parse number literal', () => {
- const node = parseExpr('42')
- assertEquals(node.type, 'literal')
-})
-
-Deno.test('Expression#parse optional chaining', () => {
- const node = parseExpr('a?.b')
- assertEquals(node.type, 'member')
-})
-
-Deno.test('Expression#parse parenthesized expression', () => {
- const node = parseExpr('(a + b) * c')
- assertEquals(node.type, 'binary')
-})
-
-Deno.test('Expression#parse relational operators', () => {
- for (const op of ['>', '<', '>=', '<=']) {
- const node = parseExpr(`a ${op} b`)
- assertEquals(node.type, 'binary')
- }
-})
-
-Deno.test('Expression#parse string literal', () => {
- const node = parseExpr('"hello"')
- assertEquals(node.type, 'literal')
-})
-
-Deno.test('Expression#parse subtraction operator', () => {
- const node = parseExpr('a - b')
- assertEquals(node.type, 'binary')
- assertEquals((node as { op: string }).op, '-')
-})
-
-Deno.test('Expression#parse ternary', () => {
- const node = parseExpr('a ? b : c')
- assertEquals(node.type, 'ternary')
-})
-
-Deno.test('Expression#parse ternary missing colon throws', () => {
- assertThrows(() => parseExpr('a ? b c'), Error, "Expected ':'")
-})
-
-Deno.test('Expression#parse unary minus', () => {
- const node = parseExpr('-5')
- assertEquals(node.type, 'unary')
-})
-
-Deno.test('Expression#parse unary not', () => {
- const node = parseExpr('!x')
- assertEquals(node.type, 'unary')
-})
-
-Deno.test('Expression#parse unary plus', () => {
- const node = parseExpr('+x')
- assertEquals(node.type, 'unary')
-})
diff --git a/tests/rendering/engine/Parser.test.ts b/tests/rendering/engine/Parser.test.ts
deleted file mode 100644
index a27998a..0000000
--- a/tests/rendering/engine/Parser.test.ts
+++ /dev/null
@@ -1,176 +0,0 @@
-import { assertEquals, assertThrows } from '@std/assert'
-import { Parser } from '@rendering/engine/Parser.ts'
-
-Deno.test('Parser#parse /each closing if block throws (mismatch)', () => {
- assertThrows(
- () => Parser.parse('{{#if ok}}{{/each}}'),
- Error,
- 'Unexpected {{/each}} without matching {{#each}}'
- )
-})
-
-Deno.test('Parser#parse /each without each throws', () => {
- assertThrows(() => Parser.parse('{{/each}}'), Error, 'Unexpected {{/each}} without matching')
-})
-
-Deno.test('Parser#parse /if closing each block throws (mismatch)', () => {
- assertThrows(
- () => Parser.parse('{{#each items}}{{/if}}'),
- Error,
- 'Unexpected {{/if}} without matching {{#if}}'
- )
-})
-
-Deno.test('Parser#parse /if without if throws', () => {
- assertThrows(() => Parser.parse('{{/if}}'), Error, 'Unexpected {{/if}} without matching')
-})
-
-Deno.test('Parser#parse consecutive tags without text between', () => {
- const nodes = Parser.parse('{{a}}{{b}}')
- assertEquals(nodes.length, 2)
- assertEquals(nodes[0]?.type, 'var')
- assertEquals((nodes[0] as { path: string }).path, 'a')
- assertEquals(nodes[1]?.type, 'var')
- assertEquals((nodes[1] as { path: string }).path, 'b')
-})
-
-Deno.test('Parser#parse each block', () => {
- const nodes = Parser.parse('{{#each items as item}}{{item}}{{/each}}')
- assertEquals(nodes.length, 1)
- assertEquals(nodes[0]?.type, 'each')
-})
-
-Deno.test('Parser#parse each with as clause using underscore identifier', () => {
- const nodes = Parser.parse('{{#each items as _item}}{{_item}}{{/each}}')
- assertEquals(nodes.length, 1)
- assertEquals(nodes[0]?.type, 'each')
- assertEquals((nodes[0] as { itemName: string }).itemName, '_item')
-})
-
-Deno.test('Parser#parse each without as clause defaults to item', () => {
- const nodes = Parser.parse('{{#each items}}{{item}}{{/each}}')
- assertEquals(nodes.length, 1)
- assertEquals((nodes[0] as { itemName: string }).itemName, 'item')
-})
-
-Deno.test('Parser#parse else inside each block throws', () => {
- assertThrows(
- () => Parser.parse('{{#each items}}{{else}}{{/each}}'),
- Error,
- 'Unexpected {{else}} without matching {{#if}}'
- )
-})
-
-Deno.test('Parser#parse else without if throws', () => {
- assertThrows(() => Parser.parse('{{else}}'), Error, 'Unexpected {{else}} without matching')
-})
-
-Deno.test('Parser#parse empty raw tag is ignored', () => {
- const nodes = Parser.parse('a{{{}}}b')
- assertEquals(nodes.length, 2)
-})
-
-Deno.test('Parser#parse empty string returns empty array', () => {
- assertEquals(Parser.parse(''), [])
-})
-
-Deno.test('Parser#parse empty tag is ignored', () => {
- const nodes = Parser.parse('a{{}}b')
- assertEquals(nodes.length, 2)
- assertEquals(nodes[0]?.type, 'text')
- assertEquals(nodes[1]?.type, 'text')
-})
-
-Deno.test('Parser#parse if block', () => {
- const nodes = Parser.parse('{{#if ok}}yes{{/if}}')
- assertEquals(nodes.length, 1)
- assertEquals(nodes[0]?.type, 'if')
-})
-
-Deno.test('Parser#parse if/else block', () => {
- const nodes = Parser.parse('{{#if ok}}yes{{else}}no{{/if}}')
- assertEquals(nodes.length, 1)
- const ifNode = nodes[0] as { thenNodes: unknown[]; elseNodes: unknown[] }
- assertEquals(ifNode.thenNodes.length, 1)
- assertEquals(ifNode.elseNodes.length, 1)
-})
-
-Deno.test('Parser#parse include tag', () => {
- const nodes = Parser.parse('{{> partial.dve}}')
- assertEquals(nodes.length, 1)
- assertEquals(nodes[0]?.type, 'include')
-})
-
-Deno.test('Parser#parse include with empty path is ignored', () => {
- const nodes = Parser.parse('a{{> }}b')
- assertEquals(nodes.length, 2)
- assertEquals(nodes[0]?.type, 'text')
- assertEquals(nodes[1]?.type, 'text')
-})
-
-Deno.test('Parser#parse mixed text and tags', () => {
- const nodes = Parser.parse('Hello {{name}}!')
- assertEquals(nodes.length, 3)
- assertEquals(nodes[0]?.type, 'text')
- assertEquals(nodes[1]?.type, 'var')
- assertEquals(nodes[2]?.type, 'text')
-})
-
-Deno.test('Parser#parse multiple else in if block pushes to elseNodes', () => {
- const nodes = Parser.parse('{{#if ok}}A{{else}}B{{else}}C{{/if}}')
- assertEquals(nodes.length, 1)
- assertEquals(nodes[0]?.type, 'if')
- const ifNode = nodes[0] as { thenNodes: unknown[]; elseNodes: unknown[] }
- assertEquals(ifNode.thenNodes.length, 1)
- assertEquals(ifNode.elseNodes.length, 2)
-})
-
-Deno.test('Parser#parse nested each blocks', () => {
- const nodes = Parser.parse('{{#each outer as o}}{{#each inner as i}}{{i}}{{/each}}{{/each}}')
- assertEquals(nodes.length, 1)
- assertEquals(nodes[0]?.type, 'each')
-})
-
-Deno.test('Parser#parse nested if blocks', () => {
- const nodes = Parser.parse('{{#if a}}{{#if b}}inner{{/if}}{{/if}}')
- assertEquals(nodes.length, 1)
- assertEquals(nodes[0]?.type, 'if')
- const inner = (nodes[0] as { thenNodes: Array<{ type: string }> }).thenNodes
- assertEquals(inner.length, 1)
- assertEquals(inner[0]?.type, 'if')
-})
-
-Deno.test('Parser#parse plain text returns text node', () => {
- const nodes = Parser.parse('Hello World')
- assertEquals(nodes.length, 1)
- assertEquals(nodes[0]?.type, 'text')
-})
-
-Deno.test('Parser#parse raw variable tag', () => {
- const nodes = Parser.parse('{{{html}}}')
- assertEquals(nodes.length, 1)
- assertEquals(nodes[0]?.type, 'var')
- assertEquals((nodes[0] as { raw: boolean }).raw, true)
-})
-
-Deno.test('Parser#parse tags with extra whitespace', () => {
- const nodes = Parser.parse('{{ name }}')
- assertEquals(nodes.length, 1)
- assertEquals(nodes[0]?.type, 'var')
- assertEquals((nodes[0] as { path: string }).path, 'name')
-})
-
-Deno.test('Parser#parse unclosed each block throws', () => {
- assertThrows(() => Parser.parse('{{#each items}}{{item}}'), Error, 'Unclosed {{#each}} block')
-})
-
-Deno.test('Parser#parse unclosed if block throws', () => {
- assertThrows(() => Parser.parse('{{#if ok}}yes'), Error, 'Unclosed {{#if}} block')
-})
-
-Deno.test('Parser#parse variable tag', () => {
- const nodes = Parser.parse('{{name}}')
- assertEquals(nodes.length, 1)
- assertEquals(nodes[0]?.type, 'var')
- assertEquals((nodes[0] as { raw: boolean }).raw, false)
-})
diff --git a/tests/rendering/engine/Tokenizer.test.ts b/tests/rendering/engine/Tokenizer.test.ts
deleted file mode 100644
index 1e533d9..0000000
--- a/tests/rendering/engine/Tokenizer.test.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-import { assertEquals, assertThrows } from '@std/assert'
-import { Tokenizer } from '@rendering/engine/Tokenizer.ts'
-
-Deno.test('Tokenizer#tokenize complex expression', () => {
- const tokens = Tokenizer.tokenize('a > 1 ? "yes" : "no"')
- assertEquals(tokens.length, 7)
- assertEquals(tokens[0]?.kind, 'ident')
- assertEquals(tokens[1]?.value, '>')
- assertEquals(tokens[2]?.kind, 'number')
- assertEquals(tokens[3]?.value, '?')
- assertEquals(tokens[4]?.kind, 'string')
- assertEquals(tokens[5]?.value, ':')
- assertEquals(tokens[6]?.kind, 'string')
-})
-
-Deno.test('Tokenizer#tokenize consecutive unary operators', () => {
- const tokens = Tokenizer.tokenize('!!!x')
- assertEquals(tokens.length, 4)
- assertEquals(tokens[0]?.kind, 'op')
- assertEquals(tokens[0]?.value, '!')
- assertEquals(tokens[1]?.kind, 'op')
- assertEquals(tokens[1]?.value, '!')
- assertEquals(tokens[2]?.kind, 'op')
- assertEquals(tokens[2]?.value, '!')
- assertEquals(tokens[3]?.kind, 'ident')
- assertEquals(tokens[3]?.value, 'x')
-})
-
-Deno.test('Tokenizer#tokenize double-quoted string', () => {
- const tokens = Tokenizer.tokenize('"world"')
- assertEquals(tokens.length, 1)
- assertEquals(tokens[0]?.kind, 'string')
- assertEquals(tokens[0]?.value, 'world')
-})
-
-Deno.test('Tokenizer#tokenize double-quoted string with escape sequences', () => {
- const tokens = Tokenizer.tokenize('"line\\nbreak"')
- assertEquals(tokens.length, 1)
- assertEquals(tokens[0]?.kind, 'string')
- assertEquals(tokens[0]?.value, 'line\nbreak')
-})
-
-Deno.test('Tokenizer#tokenize empty string literal double quotes', () => {
- const tokens = Tokenizer.tokenize('""')
- assertEquals(tokens.length, 1)
- assertEquals(tokens[0]?.kind, 'string')
- assertEquals(tokens[0]?.value, '')
-})
-
-Deno.test('Tokenizer#tokenize empty string literal single quotes', () => {
- const tokens = Tokenizer.tokenize("''")
- assertEquals(tokens.length, 1)
- assertEquals(tokens[0]?.kind, 'string')
- assertEquals(tokens[0]?.value, '')
-})
-
-Deno.test('Tokenizer#tokenize empty string returns empty array', () => {
- assertEquals(Tokenizer.tokenize(''), [])
-})
-
-Deno.test('Tokenizer#tokenize escaped backslash in string', () => {
- const tokens = Tokenizer.tokenize("'\\\\'")
- assertEquals(tokens.length, 1)
- assertEquals(tokens[0]?.kind, 'string')
- assertEquals(tokens[0]?.value, '\\')
-})
-
-Deno.test('Tokenizer#tokenize float number', () => {
- const tokens = Tokenizer.tokenize('3.14')
- assertEquals(tokens.length, 1)
- assertEquals(tokens[0]?.kind, 'number')
- assertEquals(tokens[0]?.value, 3.14)
-})
-
-Deno.test('Tokenizer#tokenize identifier', () => {
- const tokens = Tokenizer.tokenize('foo')
- assertEquals(tokens.length, 1)
- assertEquals(tokens[0]?.kind, 'ident')
- assertEquals(tokens[0]?.value, 'foo')
-})
-
-Deno.test('Tokenizer#tokenize identifier starting with $ or _ or @', () => {
- for (const name of ['$var', '_private', '@index']) {
- const tokens = Tokenizer.tokenize(name)
- assertEquals(tokens[0]?.kind, 'ident')
- assertEquals(tokens[0]?.value, name)
- }
-})
-
-Deno.test('Tokenizer#tokenize integer number', () => {
- const tokens = Tokenizer.tokenize('42')
- assertEquals(tokens.length, 1)
- assertEquals(tokens[0]?.kind, 'number')
- assertEquals(tokens[0]?.value, 42)
-})
-
-Deno.test('Tokenizer#tokenize invalid character throws', () => {
- assertThrows(() => Tokenizer.tokenize('a # b'), Error, 'Invalid DVE expression token')
-})
-
-Deno.test('Tokenizer#tokenize number followed by dot with no digits', () => {
- const tokens = Tokenizer.tokenize('5.')
- assertEquals(tokens[0]?.kind, 'number')
- assertEquals(tokens[0]?.value, 5)
-})
-
-Deno.test('Tokenizer#tokenize number followed by identifier', () => {
- const tokens = Tokenizer.tokenize('42abc')
- assertEquals(tokens.length, 2)
- assertEquals(tokens[0]?.kind, 'number')
- assertEquals(tokens[0]?.value, 42)
- assertEquals(tokens[1]?.kind, 'ident')
- assertEquals(tokens[1]?.value, 'abc')
-})
-
-Deno.test('Tokenizer#tokenize number with leading zeros', () => {
- const tokens = Tokenizer.tokenize('007')
- assertEquals(tokens.length, 1)
- assertEquals(tokens[0]?.kind, 'number')
- assertEquals(tokens[0]?.value, 7)
-})
-
-Deno.test('Tokenizer#tokenize single-char operators', () => {
- for (const op of ['(', ')', '?', ':', '.', '!', '+', '-', '*', '/', '%', '>', '<']) {
- const tokens = Tokenizer.tokenize(op)
- assertEquals(tokens[0]?.kind, 'op')
- assertEquals(tokens[0]?.value, op)
- }
-})
-
-Deno.test('Tokenizer#tokenize single-quoted string', () => {
- const tokens = Tokenizer.tokenize("'hello'")
- assertEquals(tokens.length, 1)
- assertEquals(tokens[0]?.kind, 'string')
- assertEquals(tokens[0]?.value, 'hello')
-})
-
-Deno.test('Tokenizer#tokenize string with escape sequences', () => {
- const tokens = Tokenizer.tokenize("'line1\\nline2\\ttab\\rret'")
- assertEquals(tokens[0]?.value, 'line1\nline2\ttab\rret')
-})
-
-Deno.test('Tokenizer#tokenize string with escaped quote', () => {
- const tokens = Tokenizer.tokenize("'it\\'s'")
- assertEquals(tokens[0]?.value, "it's")
-})
-
-Deno.test('Tokenizer#tokenize three-char operators === and !==', () => {
- const tokens = Tokenizer.tokenize('a === b !== c')
- assertEquals(tokens[1]?.value, '===')
- assertEquals(tokens[3]?.value, '!==')
-})
-
-Deno.test('Tokenizer#tokenize tokens without whitespace', () => {
- const tokens = Tokenizer.tokenize('"a"+"b"')
- assertEquals(tokens.length, 3)
- assertEquals(tokens[0]?.kind, 'string')
- assertEquals(tokens[0]?.value, 'a')
- assertEquals(tokens[1]?.kind, 'op')
- assertEquals(tokens[1]?.value, '+')
- assertEquals(tokens[2]?.kind, 'string')
- assertEquals(tokens[2]?.value, 'b')
-})
-
-Deno.test('Tokenizer#tokenize two-char operators', () => {
- for (const op of ['&&', '||', '??', '>=', '<=', '==', '!=', '?.']) {
- const tokens = Tokenizer.tokenize(`a ${op} b`)
- assertEquals(tokens[1]?.value, op)
- }
-})
-
-Deno.test('Tokenizer#tokenize unterminated string throws', () => {
- assertThrows(() => Tokenizer.tokenize("'no end"), Error, 'Unterminated string literal')
-})
-
-Deno.test('Tokenizer#tokenize whitespace only returns empty array', () => {
- assertEquals(Tokenizer.tokenize(' \t\n '), [])
-})
diff --git a/tests/rendering/engine/Utils.test.ts b/tests/rendering/engine/Utils.test.ts
deleted file mode 100644
index df59ef1..0000000
--- a/tests/rendering/engine/Utils.test.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-import { assertEquals } from '@std/assert'
-import { Utils } from '@rendering/engine/Utils.ts'
-
-Deno.test('Utils#escape escapes ampersand', () => {
- assertEquals(Utils.escape('a & b'), 'a & b')
-})
-
-Deno.test('Utils#escape escapes double quotes', () => {
- assertEquals(Utils.escape('"quoted"'), '"quoted"')
-})
-
-Deno.test('Utils#escape escapes less-than', () => {
- assertEquals(Utils.escape(''), '<tag>')
-})
-
-Deno.test('Utils#escape escapes single quotes', () => {
- assertEquals(Utils.escape("it's"), 'it's')
-})
-
-Deno.test('Utils#escape handles all special chars together', () => {
- assertEquals(Utils.escape('&<>"\' '), '&<>"' ')
-})
-
-Deno.test('Utils#escape passes through plain text unchanged', () => {
- assertEquals(Utils.escape('hello world'), 'hello world')
-})
-
-Deno.test('Utils#escape returns empty string for empty input', () => {
- assertEquals(Utils.escape(''), '')
-})
-
-Deno.test('Utils#join joins root and relative path', () => {
- assertEquals(Utils.join('/views', 'hello.dve'), '/views/hello.dve')
-})
-
-Deno.test('Utils#join normalizes backslashes in relative', () => {
- assertEquals(Utils.join('/views', 'sub\\hello.dve'), '/views/sub/hello.dve')
-})
-
-Deno.test('Utils#join strips leading slashes from relative', () => {
- assertEquals(Utils.join('/views', '/hello.dve'), '/views/hello.dve')
-})
-
-Deno.test('Utils#join strips trailing slashes from root', () => {
- assertEquals(Utils.join('/views///', 'hello.dve'), '/views/hello.dve')
-})
-
-Deno.test('Utils#join with empty relative', () => {
- assertEquals(Utils.join('/views', ''), '/views/')
-})
-
-Deno.test('Utils#join with empty root', () => {
- assertEquals(Utils.join('', 'file.dve'), '/file.dve')
-})
-
-Deno.test('Utils#lookup does not resolve inherited __proto__', () => {
- assertEquals(Utils.lookup({}, '__proto__'), undefined)
-})
-
-Deno.test('Utils#lookup does not resolve inherited constructor', () => {
- assertEquals(Utils.lookup({}, 'constructor'), undefined)
-})
-
-Deno.test('Utils#lookup does not resolve inherited member mid-path', () => {
- assertEquals(Utils.lookup({ a: { x: 1 } }, 'a.constructor'), undefined)
-})
-
-Deno.test('Utils#lookup does not resolve inherited toString', () => {
- assertEquals(Utils.lookup({}, 'toString'), undefined)
-})
-
-Deno.test('Utils#lookup does not resolve string prototype __proto__', () => {
- assertEquals(Utils.lookup({ s: 'abc' }, 's.__proto__'), undefined)
-})
-
-Deno.test('Utils#lookup does not resolve string prototype constructor', () => {
- assertEquals(Utils.lookup({ s: 'abc' }, 's.constructor'), undefined)
-})
-
-Deno.test('Utils#lookup does not resolve string prototype method', () => {
- assertEquals(Utils.lookup({ s: 'abc' }, 's.toUpperCase'), undefined)
-})
-
-Deno.test('Utils#lookup handles empty segments in path', () => {
- assertEquals(Utils.lookup({ a: { b: 1 } }, 'a..b'), 1)
-})
-
-Deno.test('Utils#lookup reads a nested string length through a multi-segment path', () => {
- assertEquals(Utils.lookup({ obj: { s: 'hello' } }, 'obj.s.length'), 5)
-})
-
-Deno.test('Utils#lookup reads own char index of a string primitive', () => {
- assertEquals(Utils.lookup({ s: 'hi' }, 's.1'), 'i')
-})
-
-Deno.test('Utils#lookup reads own length of a string primitive', () => {
- assertEquals(Utils.lookup({ s: 'hello' }, 's.length'), 5)
-})
-
-Deno.test('Utils#lookup resolves an own key that shadows a builtin name', () => {
- assertEquals(Utils.lookup({ toString: 'mine' }, 'toString'), 'mine')
-})
-
-Deno.test('Utils#lookup resolves dotted path', () => {
- assertEquals(Utils.lookup({ a: { b: { c: 42 } } }, 'a.b.c'), 42)
-})
-
-Deno.test('Utils#lookup resolves simple key', () => {
- assertEquals(Utils.lookup({ name: 'Alice' }, 'name'), 'Alice')
-})
-
-Deno.test('Utils#lookup returns root object for empty path', () => {
- const obj = { x: 1 }
- assertEquals(Utils.lookup(obj, ''), obj)
-})
-
-Deno.test('Utils#lookup returns undefined for an out-of-range string char index', () => {
- assertEquals(Utils.lookup({ s: 'hi' }, 's.5'), undefined)
-})
-
-Deno.test('Utils#lookup returns undefined for missing path', () => {
- assertEquals(Utils.lookup({ a: 1 }, 'b'), undefined)
-})
-
-Deno.test('Utils#lookup returns undefined for null object', () => {
- assertEquals(Utils.lookup(null, 'a'), undefined)
-})
-
-Deno.test('Utils#lookup returns undefined for undefined object', () => {
- assertEquals(Utils.lookup(undefined, 'a'), undefined)
-})
-
-Deno.test('Utils#lookup returns undefined when traversing non-object', () => {
- assertEquals(Utils.lookup({ a: 42 }, 'a.b'), undefined)
-})
-
-Deno.test('Utils#lookup with array data and numeric string key', () => {
- assertEquals(Utils.lookup([10, 20, 30], '1'), 20)
-})
-
-Deno.test('Utils#lookup with mid-chain null', () => {
- assertEquals(Utils.lookup({ a: { b: null } }, 'a.b.c'), undefined)
-})
-
-Deno.test('Utils#lookup with path containing spaces around dots', () => {
- assertEquals(Utils.lookup({ a: { b: 1 } }, 'a . b'), 1)
-})
From 626b0cbef47ebcace860eb786b2b6a983c5ae222 Mon Sep 17 00:00:00 2001
From: NeaByteLab <209737579+NeaByteLab@users.noreply.github.com>
Date: Thu, 25 Jun 2026 22:14:25 +0700
Subject: [PATCH 05/40] refactor(core)!: fold process guard into Observability
and rename events
- Add cors, auth, ip, validate, body, websocket, and static events
- Install process capture lazily and restore handlers on last unsubscribe
- Move unhandled rejection and exit interposition out of Guard
- Remove standalone Guard module and its interface
- Rename event taxonomy to past-tense lifecycle kinds
BREAKING CHANGE: Guard export is removed and observability event names are renamed (e.g. server:listening to server:started, request:complete to request:completed, worker:crash to worker:crashed)
---
src/core/Guard.ts | 141 --------------------
src/core/Observability.ts | 203 ++++++++++++++++++++++++----
src/interfaces/Observability.ts | 97 --------------
tests/core/Guard.test.ts | 157 ----------------------
tests/core/Observability.test.ts | 218 +++++++------------------------
5 files changed, 221 insertions(+), 595 deletions(-)
delete mode 100644 src/core/Guard.ts
delete mode 100644 src/interfaces/Observability.ts
delete mode 100644 tests/core/Guard.test.ts
diff --git a/src/core/Guard.ts b/src/core/Guard.ts
deleted file mode 100644
index 4c75c61..0000000
--- a/src/core/Guard.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-import type * as Types from '@interfaces/index.ts'
-import * as Core from '@core/index.ts'
-
-/**
- * Process-level fault sentinel for multi-service uptime.
- * @description Traps unhandled rejections and uncaught errors process-wide.
- */
-export class Guard {
- /** Registered emitters, one per serving Router */
- private static readonly emitters = new Set()
- /** True once the global listeners have been attached */
- private static installed = false
-
- /**
- * Register a Router emitter and activate guard.
- * @description Attaches global listeners exactly once across routers.
- * @param emit - The Router's observability emit function
- * @returns Unregister function removing this emitter from the fan-out
- */
- static register(emit: Types.EventEmit): () => void {
- Guard.emitters.add(emit)
- Guard.install()
- return () => {
- Guard.emitters.delete(emit)
- }
- }
-
- /**
- * Fan a fault to registered emitters.
- * @description A faulty subscriber must not break delivery to the others.
- * @param origin - Which global hook produced the fault
- * @param error - Normalized Error describing the fault
- */
- private static dispatch(
- origin: 'unhandledrejection' | 'uncaughterror' | 'process:exit',
- error: Error
- ): void {
- for (const emit of Guard.emitters) {
- try {
- emit(Core.Observability.internalEvent('process:error', { origin, error }))
- } catch {
- void 0
- }
- }
- }
-
- /** Attach global rejection and error listeners once */
- private static install(): void {
- if (Guard.installed) {
- return
- }
- Guard.installed = true
- globalThis.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
- event.preventDefault()
- Guard.dispatch('unhandledrejection', Guard.toError(event.reason))
- })
- globalThis.addEventListener('error', (event: ErrorEvent) => {
- event.preventDefault()
- Guard.dispatch('uncaughterror', Guard.toError(event.error ?? event.message))
- })
- Guard.interposeExitCapabilities()
- }
-
- /**
- * Neutralize every native process-termination capability.
- * @description Blocks exit, abort, and self-targeted kills.
- */
- private static interposeExitCapabilities(): void {
- const ownPid = Deno.pid
- const targetsSelf = (args: readonly unknown[]): boolean => args[0] === ownPid
- const deno = Deno as unknown as Record
- Guard.interposeMethod(deno, 'exit', 'Deno.exit')
- Guard.interposeMethod(deno, 'kill', 'Deno.kill', targetsSelf)
- const proc = (globalThis as unknown as { process?: Record }).process
- if (proc) {
- Guard.interposeMethod(proc, 'exit', 'process.exit')
- Guard.interposeMethod(proc, 'abort', 'process.abort')
- Guard.interposeMethod(proc, 'reallyExit', 'process.reallyExit')
- Guard.interposeMethod(proc, 'kill', 'process.kill', targetsSelf)
- }
- }
-
- /**
- * Replace one termination method with a guarded no-op.
- * @description Predicate-matched calls block, others delegate through.
- * @param target - Object owning the method (Deno or node process)
- * @param name - Method name to interpose
- * @param label - Human-readable name for the blocked capability
- * @param shouldBlock - Optional guard, when omitted every call is blocked
- */
- private static interposeMethod(
- target: Record,
- name: string,
- label: string,
- shouldBlock?: (args: readonly unknown[]) => boolean
- ): void {
- const original = target[name]
- if (typeof original !== 'function') {
- return
- }
- const realFn = original as (...args: unknown[]) => unknown
- const guarded = (...args: unknown[]): unknown => {
- if (shouldBlock && !shouldBlock(args)) {
- return realFn.apply(target, args)
- }
- Guard.dispatch(
- 'process:exit',
- new Error(
- `Blocked ${label}(${
- args.map((value) => String(value)).join(', ')
- }) — process termination is not permitted from application code`
- )
- )
- return undefined
- }
- try {
- Object.defineProperty(target, name, {
- value: guarded,
- writable: true,
- configurable: true
- })
- } catch {
- void 0
- }
- }
-
- /**
- * Normalize a thrown value into Error.
- * @description Wraps non-Error reasons into a real Error.
- * @param reason - The rejection reason or thrown value
- * @returns A normalized Error instance
- */
- private static toError(reason: unknown): Error {
- if (reason instanceof Error) {
- return reason
- }
- return new Error(
- typeof reason === 'string' ? reason : `Unhandled rejection caused by ${String(reason)}`
- )
- }
-}
diff --git a/src/core/Observability.ts b/src/core/Observability.ts
index 135e158..91fcfdd 100644
--- a/src/core/Observability.ts
+++ b/src/core/Observability.ts
@@ -2,39 +2,56 @@ import type * as Types from '@interfaces/index.ts'
import { createSignal } from '@neabyte/utils-core'
/**
- * Central lifecycle and error event bus.
- * @description Wraps a typed signal isolating faulty subscriber errors.
+ * Event emitter and process guard.
+ * @description Emits events and intercepts process termination.
*/
export class Observability {
- /** Underlying typed signal carrying Deserve events */
- private readonly signal = createSignal<[Types.EventBase]>()
- /** Active subscriber count */
- private listeners = 0
+ /** Internal signal carrying event payloads */
+ readonly #signal = createSignal<[Types.EventBase]>()
+ /** Active listener count for emit gating */
+ #listenerCount = 0
+ /** Cleanup function for process capture */
+ #captureProcessErrors: (() => void) | null = null
/**
- * Emit one event to listeners.
- * @description No-op when there are no subscribers.
- * @param event - Event payload to broadcast
+ * Emit event to listeners.
+ * @description Skips emit when no listeners exist.
+ * @param event - Event payload to emit
*/
emit(event: Types.EventBase): void {
- if (this.listeners === 0) {
+ if (this.#listenerCount === 0) {
return
}
- this.signal.emit(event)
+ this.#signal.emit(event)
}
- /** Report whether any subscriber is registered */
+ /**
+ * Build external event payload.
+ * @description Stamps type external and current timestamp.
+ * @param kind - Event kind discriminator
+ * @param metadata - Event metadata for kind
+ * @returns Typed external event payload
+ * @template Kind - Event kind being built
+ */
+ static externalEvent(
+ kind: Kind,
+ metadata: Types.EventByKind['metadata']
+ ): Types.EventByKind {
+ return { type: 'external', kind, metadata, timestamp: Date.now() } as Types.EventByKind
+ }
+
+ /** Check whether any listeners exist */
hasListeners(): boolean {
- return this.listeners > 0
+ return this.#listenerCount > 0
}
/**
- * Build an internal lifecycle event.
+ * Build internal event payload.
* @description Stamps type internal and current timestamp.
- * @template Kind - Event kind discriminant literal
- * @param kind - Event kind discriminant
- * @param metadata - Metadata matching the kind
- * @returns Fully formed internal event
+ * @param kind - Event kind discriminator
+ * @param metadata - Event metadata for kind
+ * @returns Typed internal event payload
+ * @template Kind - Event kind being built
*/
static internalEvent(
kind: Kind,
@@ -44,22 +61,156 @@ export class Observability {
}
/**
- * Subscribe to every Deserve event.
- * @description Listener receives all event types, filter via type.
- * @param listener - Callback invoked for each event
- * @returns Unsubscribe function
+ * Subscribe listener to events.
+ * @description Installs process capture on first listener.
+ * @param listener - Event listener callback
+ * @returns Unsubscribe function for listener
*/
- on(listener: Types.EventListener): () => void {
- const unsubscribe = this.signal.subscribe(listener)
- this.listeners += 1
+ on(listener: Types.EventFn): () => void {
+ const unsubscribe = this.#signal.subscribe(listener)
+ this.#listenerCount += 1
+ if (this.#listenerCount === 1) {
+ this.#captureProcessErrors = Observability.#installProcessCapture((event) => this.emit(event))
+ }
let active = true
return () => {
if (!active) {
return
}
active = false
- this.listeners -= 1
+ this.#listenerCount -= 1
unsubscribe()
+ if (this.#listenerCount === 0 && this.#captureProcessErrors !== null) {
+ this.#captureProcessErrors()
+ this.#captureProcessErrors = null
+ }
+ }
+ }
+
+ /**
+ * Install global process error capture.
+ * @description Listens for rejections, errors, and exits.
+ * @param emit - Event emitter for captured errors
+ * @returns Cleanup function removing capture
+ */
+ static #installProcessCapture(emit: Types.EventFn): () => void {
+ const onRejection = (event: PromiseRejectionEvent) => {
+ event.preventDefault()
+ emit(Observability.#processError('unhandledrejection', event.reason))
+ }
+ const onError = (event: ErrorEvent) => {
+ event.preventDefault()
+ emit(Observability.#processError('uncaughterror', event.error ?? event.message))
+ }
+ globalThis.addEventListener('unhandledrejection', onRejection)
+ globalThis.addEventListener('error', onError)
+ const restoreExits = Observability.#interposeExits(emit)
+ return () => {
+ globalThis.removeEventListener('unhandledrejection', onRejection)
+ globalThis.removeEventListener('error', onError)
+ restoreExits()
+ }
+ }
+
+ /**
+ * Interpose process exit methods.
+ * @description Guards Deno and process termination methods.
+ * @param emit - Event emitter for blocked calls
+ * @returns Cleanup function restoring methods
+ */
+ static #interposeExits(emit: Types.EventFn): () => void {
+ const ownPid = Deno.pid
+ const targetsSelf = (args: readonly unknown[]): boolean => args[0] === ownPid
+ const deno = Deno as unknown as Record
+ const proc = (globalThis as unknown as Types.ProcessGlobal).process
+ const restores: Array<() => void> = []
+ restores.push(Observability.#interposeMethod(emit, deno, 'exit', 'Deno.exit'))
+ restores.push(Observability.#interposeMethod(emit, deno, 'kill', 'Deno.kill', targetsSelf))
+ if (proc) {
+ restores.push(Observability.#interposeMethod(emit, proc, 'exit', 'process.exit'))
+ restores.push(Observability.#interposeMethod(emit, proc, 'abort', 'process.abort'))
+ restores.push(Observability.#interposeMethod(emit, proc, 'reallyExit', 'process.reallyExit'))
+ restores.push(Observability.#interposeMethod(emit, proc, 'kill', 'process.kill', targetsSelf))
+ }
+ return () => {
+ for (const restore of restores) {
+ restore()
+ }
+ }
+ }
+
+ /**
+ * Replace target method with guard.
+ * @description Blocks termination and emits process error.
+ * @param emit - Event emitter for blocked calls
+ * @param target - Object owning the method
+ * @param name - Method name to interpose
+ * @param label - Label used in error message
+ * @param shouldBlock - Predicate deciding when to block
+ * @returns Cleanup function restoring method
+ */
+ static #interposeMethod(
+ emit: Types.EventFn,
+ target: Record,
+ name: string,
+ label: string,
+ shouldBlock?: (args: readonly unknown[]) => boolean
+ ): () => void {
+ const original = target[name]
+ if (typeof original !== 'function') {
+ return () => {}
+ }
+ const realFn = original as (...args: unknown[]) => unknown
+ const guarded = (...args: unknown[]): unknown => {
+ if (shouldBlock && !shouldBlock(args)) {
+ return realFn.apply(target, args)
+ }
+ emit(
+ Observability.#processError(
+ 'process:exit',
+ new Error(
+ `Blocked ${label}(${
+ args.map((value) => String(value)).join(', ')
+ }) process termination is not permitted from application code`
+ )
+ )
+ )
+ return undefined
+ }
+ try {
+ Object.defineProperty(target, name, { value: guarded, writable: true, configurable: true })
+ } catch {
+ return () => {}
+ }
+ return () => {
+ try {
+ Object.defineProperty(target, name, {
+ value: realFn,
+ writable: true,
+ configurable: true
+ })
+ } catch {
+ void 0
+ }
+ }
+ }
+
+ /**
+ * Build process error event.
+ * @description Wraps reason into error event payload.
+ * @param origin - Process error origin label
+ * @param reason - Underlying error reason value
+ * @returns Process error event payload
+ */
+ static #processError(origin: Types.ProcessErrorOrigin, reason: unknown): Types.EventBase {
+ const error = reason instanceof Error
+ ? reason
+ : new Error(typeof reason === 'string' ? reason : `Process error from ${String(reason)}`)
+ return {
+ type: 'external',
+ kind: 'process:failed',
+ metadata: { origin, error },
+ timestamp: Date.now()
}
}
}
diff --git a/src/interfaces/Observability.ts b/src/interfaces/Observability.ts
deleted file mode 100644
index 6753073..0000000
--- a/src/interfaces/Observability.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import type * as Types from '@interfaces/index.ts'
-
-/** Metadata atom carrying an Error. */
-type ErrorMeta = {
- /** Error instance describing the fault */
- error: Error
-}
-
-/** Metadata atom carrying a route path. */
-type RouteMeta = {
- /** Registered route path string */
- routePath: string
-}
-
-/**
- * Discriminated union of lifecycle events.
- * @description Discriminated by kind, with fields under metadata.
- */
-export type EventBase =
- | LifecycleEvent<'server:listening', { port: number; hostname: string }>
- | LifecycleEvent<'server:shutdown', Record>
- | LifecycleEvent<
- 'route:loaded' | 'route:reloaded' | 'route:removed',
- RouteMeta & { pattern: string }
- >
- | LifecycleEvent<'route:skipped', RouteMeta & { reason: string }>
- | LifecycleEvent<'route:error' | 'reload:error', RouteMeta & ErrorMeta>
- | LifecycleEvent<
- 'process:error',
- ErrorMeta & { origin: 'unhandledrejection' | 'uncaughterror' | 'process:exit' }
- >
- | LifecycleEvent<'view:compiled' | 'view:rendered', { path: string; durationMs: number }>
- | LifecycleEvent<'view:refreshed', { paths: readonly string[] }>
- | LifecycleEvent<'view:error', { path: string } & ErrorMeta>
- | LifecycleEvent<
- 'session:invalid',
- { cookieName: string; reason: 'tampered' | 'expired' | 'malformed' }
- >
- | LifecycleEvent<'csrf:rule-error', { rule: 'origin' | 'secFetchSite' } & ErrorMeta>
- | LifecycleEvent<'worker:timeout', { timeoutMs: number; workerIndex: number } & ErrorMeta>
- | LifecycleEvent<'worker:crash', { workerIndex: number } & ErrorMeta>
- | LifecycleEvent<'worker:respawn', { workerIndex: number }>
- | LifecycleEvent<
- 'worker:rejected',
- { reason: 'queue-depth' | 'queue-wait'; queueDepth: number; maxQueueDepth: number }
- >
- | LifecycleEvent<
- 'request:complete' | 'request:error',
- & {
- method: string
- statusCode: number
- url: string
- durationMs: number
- ip?: string
- }
- & Types.RequestMetrics
- & Partial
- >
-
-/**
- * Event member selected by kind.
- * @description Distributes over the union to keep grouped kinds.
- * @template Kind - Event kind discriminant literal
- */
-export type EventByKind = EventBase extends infer Member
- ? Member extends { kind: infer MemberKind } ? Kind extends MemberKind ? Member : never
- : never
- : never
-
-/** Origin channel of an event. */
-export type EventChannel = 'internal' | 'external'
-
-/** Emit function passed into internal subsystems. */
-export type EventEmit = (event: EventBase) => void
-
-/** Discriminant value of a lifecycle event. */
-export type EventKind = EventBase['kind']
-
-/** Listener invoked for emitted events. */
-export type EventListener = (event: EventBase) => void
-
-/**
- * Lifecycle event envelope with metadata.
- * @description Pairs a kind discriminant with its readonly metadata.
- * @template Kind - Event kind discriminant literal
- * @template Metadata - Event-specific metadata shape
- */
-export type LifecycleEvent = {
- /** Origin channel of the event */
- readonly type: EventChannel
- /** Event kind discriminant value */
- readonly kind: Kind
- /** Readonly event-specific metadata */
- readonly metadata: Readonly
- /** Creation time in epoch milliseconds */
- readonly timestamp: number
-}
diff --git a/tests/core/Guard.test.ts b/tests/core/Guard.test.ts
deleted file mode 100644
index 1911631..0000000
--- a/tests/core/Guard.test.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-import { assertEquals } from '@std/assert'
-import * as Core from '@core/index.ts'
-import type * as Types from '@interfaces/index.ts'
-
-Deno.test('Guard blocks a self-kill but lets other-PID kills pass through', () => {
- const received: Types.EventBase[] = []
- const unregister = Core.Guard.register((event) => {
- if (event.kind === 'process:error') {
- received.push(event)
- }
- })
- try {
- const denoKill = Deno.kill as unknown as (pid: number, signal?: string) => void
- denoKill(Deno.pid, 'SIGTERM')
- const meta = received.at(-1)!.metadata as { origin: string; error: Error }
- assertEquals(meta.origin, 'process:exit')
- assertEquals(meta.error.message.includes('Deno.kill('), true)
- const before = received.length
- let passedThrough = false
- try {
- denoKill(2147483646, 'SIGTERM')
- } catch {
- passedThrough = true
- }
- assertEquals(passedThrough, true)
- assertEquals(received.length, before)
- } finally {
- unregister()
- }
-})
-
-Deno.test('Guard neutralizes Deno.exit, keeps running, and emits process:error', () => {
- const received: Types.EventBase[] = []
- const unregister = Core.Guard.register((event) => {
- if (event.kind === 'process:error') {
- received.push(event)
- }
- })
- try {
- const denoExit = Deno.exit as unknown as (code?: number) => void
- denoExit(42)
- assertEquals(received.length >= 1, true)
- const meta = received.at(-1)!.metadata as { origin: string; error: Error }
- assertEquals(meta.origin, 'process:exit')
- assertEquals(meta.error instanceof Error, true)
- assertEquals(meta.error.message.includes('Deno.exit(42)'), true)
- } finally {
- unregister()
- }
-})
-
-Deno.test('Guard neutralizes node process.exit, keeps running, and emits process:error', () => {
- const proc = (globalThis as { process?: { exit?: (code?: number) => void } }).process
- if (!proc || typeof proc.exit !== 'function') {
- return
- }
- const received: Types.EventBase[] = []
- const unregister = Core.Guard.register((event) => {
- if (event.kind === 'process:error') {
- received.push(event)
- }
- })
- try {
- proc.exit(7)
- assertEquals(received.length >= 1, true)
- const meta = received.at(-1)!.metadata as { origin: string; error: Error }
- assertEquals(meta.origin, 'process:exit')
- assertEquals(meta.error.message.includes('process.exit(7)'), true)
- } finally {
- unregister()
- }
-})
-
-Deno.test('Guard neutralizes process.abort and process.reallyExit, keeps running', () => {
- const proc = (
- globalThis as {
- process?: { abort?: () => void; reallyExit?: (code?: number) => void }
- }
- ).process
- if (!proc || typeof proc.abort !== 'function' || typeof proc.reallyExit !== 'function') {
- return
- }
- const received: Types.EventBase[] = []
- const unregister = Core.Guard.register((event) => {
- if (event.kind === 'process:error') {
- received.push(event)
- }
- })
- try {
- proc.abort()
- proc.reallyExit(3)
- const messages = received.map((event) => (event.metadata as { error: Error }).error.message)
- assertEquals(
- messages.some((message) => message.includes('process.abort(')),
- true
- )
- assertEquals(
- messages.some((message) => message.includes('process.reallyExit(3)')),
- true
- )
- } finally {
- unregister()
- }
-})
-
-Deno.test(
- 'Guard traps an unhandled rejection, keeps running, and emits process:error',
- async () => {
- const received: Types.EventBase[] = []
- const unregister = Core.Guard.register((event) => {
- if (event.kind === 'process:error') {
- received.push(event)
- }
- })
- try {
- void (async () => {
- throw new Error('guard regression floating rejection')
- })()
- await new Promise((resolve) => setTimeout(resolve, 50))
- assertEquals(received.length >= 1, true)
- const meta = received[0]!.metadata as { origin: string; error: Error }
- assertEquals(meta.origin, 'unhandledrejection')
- assertEquals(meta.error instanceof Error, true)
- } finally {
- unregister()
- }
- }
-)
-
-Deno.test('Guard#register install is idempotent across many registrations', () => {
- const unsubscribers: Array<() => void> = []
- try {
- for (let i = 0; i < 10; i++) {
- unsubscribers.push(Core.Guard.register(() => {}))
- }
- assertEquals(unsubscribers.length, 10)
- } finally {
- for (const off of unsubscribers) {
- off()
- }
- }
-})
-
-Deno.test('Guard#register unregister removes the emitter from the fan-out', async () => {
- const received: Types.EventBase[] = []
- const unregister = Core.Guard.register((event) => {
- if (event.kind === 'process:error') {
- received.push(event)
- }
- })
- unregister()
- void (async () => {
- throw new Error('after-unregister rejection')
- })()
- await new Promise((resolve) => setTimeout(resolve, 50))
- assertEquals(received.length, 0)
-})
diff --git a/tests/core/Observability.test.ts b/tests/core/Observability.test.ts
index df86b9e..110f531 100644
--- a/tests/core/Observability.test.ts
+++ b/tests/core/Observability.test.ts
@@ -1,197 +1,67 @@
-import type * as Types from '@interfaces/index.ts'
import { assertEquals } from '@std/assert'
import * as Core from '@core/index.ts'
-Deno.test('Observability delivers sequential events in order to one subscriber', () => {
- const bus = new Core.Observability()
+Deno.test('Observability emit delivers events to listeners', () => {
+ const obs = new Core.Observability()
const kinds: string[] = []
- bus.on((event) => kinds.push(event.kind))
- bus.emit({
- type: 'internal',
- kind: 'view:compiled',
- metadata: { path: '/a.dve', durationMs: 2 },
- timestamp: 1
- })
- bus.emit({
- type: 'internal',
- kind: 'view:rendered',
- metadata: { path: '/a.dve', durationMs: 3 },
- timestamp: 2
- })
- assertEquals(kinds, ['view:compiled', 'view:rendered'])
-})
-
-Deno.test('Observability delivers the full event payload to a subscriber', () => {
- const bus = new Core.Observability()
- let captured: Types.EventBase | null = null
- bus.on((event) => {
- captured = event
- })
- bus.emit({
- type: 'internal',
- kind: 'server:listening',
- metadata: { port: 8000, hostname: '0.0.0.0' },
- timestamp: 123
- })
- const event = captured as unknown as {
- type: string
- kind: string
- metadata: { port: number; hostname: string }
- timestamp: number
- }
- assertEquals(event.type, 'internal')
- assertEquals(event.kind, 'server:listening')
- assertEquals(event.metadata.port, 8000)
- assertEquals(event.metadata.hostname, '0.0.0.0')
- assertEquals(event.timestamp, 123)
-})
-
-Deno.test('Observability emit delivers events to subscriber', () => {
- const bus = new Core.Observability()
- const received: Types.EventBase[] = []
- bus.on((event) => received.push(event))
- bus.emit({
- type: 'internal',
- kind: 'server:listening',
- metadata: { port: 8000, hostname: '0.0.0.0' },
- timestamp: Date.now()
- })
- assertEquals(received.length, 1)
- assertEquals(received[0]?.kind, 'server:listening')
-})
-
-Deno.test('Observability emit does not throw when a listener throws', () => {
- const bus = new Core.Observability()
- bus.on(() => {
- throw new Error('listener boom')
- })
- let emitThrew = false
- try {
- bus.emit({
- type: 'internal',
- kind: 'route:loaded',
- metadata: { routePath: 'a.ts', pattern: '/a' },
- timestamp: Date.now()
- })
- } catch {
- emitThrew = true
- }
- assertEquals(emitThrew, false)
-})
-
-Deno.test('Observability emit is a no-op while no listener is registered', () => {
- const bus = new Core.Observability()
- let delivered = 0
- const unsub = bus.on(() => delivered++)
+ const unsub = obs.on((event) => kinds.push(event.kind))
+ obs.emit(Core.Observability.internalEvent('server:stopped', {}))
unsub()
- bus.emit({
- type: 'internal',
- kind: 'route:loaded',
- metadata: { routePath: 'd.ts', pattern: '/d' },
- timestamp: Date.now()
- })
- assertEquals(delivered, 0)
- assertEquals(bus.hasListeners(), false)
+ assertEquals(kinds.includes('server:stopped'), true)
})
-Deno.test('Observability emit with no subscriber is a safe no-op', () => {
- const bus = new Core.Observability()
- bus.emit({
- type: 'internal',
- kind: 'route:removed',
- metadata: { routePath: 'x.ts', pattern: '/x' },
- timestamp: Date.now()
- })
+Deno.test('Observability emit is skipped without listeners', () => {
+ const obs = new Core.Observability()
+ obs.emit(Core.Observability.internalEvent('server:stopped', {}))
+ assertEquals(obs.hasListeners(), false)
})
-Deno.test('Observability hasListeners reflects subscribe and unsubscribe', () => {
- const bus = new Core.Observability()
- assertEquals(bus.hasListeners(), false)
- const first = bus.on(() => {})
- assertEquals(bus.hasListeners(), true)
- const second = bus.on(() => {})
- assertEquals(bus.hasListeners(), true)
- first()
- assertEquals(bus.hasListeners(), true)
- second()
- assertEquals(bus.hasListeners(), false)
+Deno.test('Observability externalEvent stamps external type', () => {
+ const event = Core.Observability.externalEvent('server:started', {
+ port: 8000,
+ hostname: '0.0.0.0'
+ })
+ assertEquals(event.type, 'external')
+ assertEquals(event.kind, 'server:started')
})
-Deno.test('Observability hasListeners stays correct under repeated unsubscribe', () => {
- const bus = new Core.Observability()
- const unsub = bus.on(() => {})
- assertEquals(bus.hasListeners(), true)
- unsub()
- unsub()
- unsub()
- assertEquals(bus.hasListeners(), false)
- let delivered = 0
- bus.on(() => delivered++)
- bus.emit({
- type: 'internal',
- kind: 'route:loaded',
- metadata: { routePath: 'c.ts', pattern: '/c' },
- timestamp: Date.now()
- })
- assertEquals(delivered, 1)
+Deno.test('Observability has no listeners initially', () => {
+ const obs = new Core.Observability()
+ assertEquals(obs.hasListeners(), false)
})
-Deno.test('Observability isolates a throwing listener from others', () => {
- const bus = new Core.Observability()
- let secondRan = false
- bus.on(() => {
- throw new Error('listener boom')
- })
- bus.on(() => {
- secondRan = true
- })
- bus.emit({
- type: 'internal',
- kind: 'view:refreshed',
- metadata: { paths: ['/v.dve'] },
- timestamp: Date.now()
- })
- assertEquals(secondRan, true)
+Deno.test('Observability internalEvent stamps internal type', () => {
+ const event = Core.Observability.internalEvent('server:stopped', {})
+ assertEquals(event.type, 'internal')
+ assertEquals(event.kind, 'server:stopped')
+ assertEquals(typeof event.timestamp, 'number')
})
-Deno.test('Observability supports multiple subscribers', () => {
- const bus = new Core.Observability()
+Deno.test('Observability multiple listeners each receive events', () => {
+ const obs = new Core.Observability()
let a = 0
let b = 0
- bus.on(() => a++)
- bus.on(() => b++)
- bus.emit({
- type: 'internal',
- kind: 'request:error',
- metadata: {
- method: 'GET',
- statusCode: 404,
- url: 'http://localhost/x',
- durationMs: 1,
- error: new Error('nope')
- },
- timestamp: Date.now()
- })
+ const unsubA = obs.on(() => a++)
+ const unsubB = obs.on(() => b++)
+ obs.emit(Core.Observability.internalEvent('server:stopped', {}))
+ unsubA()
+ unsubB()
assertEquals(a, 1)
assertEquals(b, 1)
})
-Deno.test('Observability unsubscribe stops delivery', () => {
- const bus = new Core.Observability()
- let count = 0
- const unsub = bus.on(() => count++)
- bus.emit({
- type: 'internal',
- kind: 'route:loaded',
- metadata: { routePath: 'a.ts', pattern: '/a' },
- timestamp: Date.now()
- })
+Deno.test('Observability on registers a listener', () => {
+ const obs = new Core.Observability()
+ const unsub = obs.on(() => {})
+ assertEquals(obs.hasListeners(), true)
unsub()
- bus.emit({
- type: 'internal',
- kind: 'route:loaded',
- metadata: { routePath: 'b.ts', pattern: '/b' },
- timestamp: Date.now()
- })
- assertEquals(count, 1)
+ assertEquals(obs.hasListeners(), false)
+})
+
+Deno.test('Observability unsubscribe is idempotent', () => {
+ const obs = new Core.Observability()
+ const unsub = obs.on(() => {})
+ unsub()
+ unsub()
+ assertEquals(obs.hasListeners(), false)
})
From 2c2f9eb44542b5f5a431f531b79b23851fcabe9c Mon Sep 17 00:00:00 2001
From: NeaByteLab <209737579+NeaByteLab@users.noreply.github.com>
Date: Thu, 25 Jun 2026 22:14:34 +0700
Subject: [PATCH 06/40] refactor(core)!: redesign Context into get/set/send
namespaces
- Add frozen ctx.get accessors for request, body, and injected state
- Add ctx.set chainable header, cookie, and session writers
- Add ctx.send helpers including download and empty responses
- Inline the Response factory into private Context build logic
- Install session, validated, and worker state via typed controllers
- Throw 409 on a conflicting second body read
BREAKING CHANGE: flat Context accessors, the state bag, the InternalContext symbol, and the Response module are removed in favor of ctx.get/ctx.set/ctx.send and Context.internalOf
---
src/core/Context.ts | 899 +++++++++++++---------------
src/core/Response.ts | 190 ------
src/interfaces/Core.ts | 854 ++++++++++++++++++++-------
tests/core/Context.test.ts | 1095 ++++-------------------------------
tests/core/Response.test.ts | 398 -------------
tests/helper.ts | 15 +
6 files changed, 1189 insertions(+), 2262 deletions(-)
delete mode 100644 src/core/Response.ts
delete mode 100644 tests/core/Response.test.ts
create mode 100644 tests/helper.ts
diff --git a/src/core/Context.ts b/src/core/Context.ts
index 0c55170..7852fd8 100644
--- a/src/core/Context.ts
+++ b/src/core/Context.ts
@@ -2,440 +2,511 @@ import type * as Types from '@interfaces/index.ts'
import * as Core from '@core/index.ts'
import { Immutable } from '@neabyte/utils-core'
-/** Symbol channel for internal Context surface */
-export const InternalContext: unique symbol = Symbol('deserve.internal.context')
-
/**
- * Request wrapper with parsed body.
- * @description Parses body once, exposes headers, cookies, state.
+ * Per request context object.
+ * @description Exposes request reading and response building helpers.
*/
export class Context {
- /** Parsed body, undefined until parsed */
- private bodyData: unknown = undefined
- /** Format body was parsed as */
- private bodyParsedAs: Types.BodyParsedFormat | null = null
- /** Parsed cookie name-to-value map, lazy */
- private cookieMap: Types.StringRecord | undefined = undefined
- /** Custom error handler when set */
- private errorHandler: Types.ErrorHandler | undefined = undefined
- /** Framework error captured by handleError */
- private frameworkError: Error | null = null
- /** Incoming fetch Request */
- private req: Request
- /** Arbitrary state for middleware/handlers */
- private requestState: Types.DataRecord = {}
- /** Private framework-wired state, not exposed via ctx.state */
- private frameworkState: Types.DataRecord = Object.create(null)
- /** Response headers to send */
- private responseHeaders: Types.StringRecord = {}
- /** Cached send helpers, lazy */
- private sendHelpers: Types.SendHelpers | undefined = undefined
- /** Set-Cookie values accumulated via setHeader */
- private setCookieValues: string[] = []
- /** Matched route path params */
- private routeParams: Types.StringRecord
- /** Parsed request URL */
- private parsedUrl: URL
- /** Connection peer IP address */
- private clientIpValue: string | undefined
- /** Direct TCP peer IP address */
- private directIpValue: string | undefined
- /** Optional router event emitter for observability */
- private emit: Types.EventEmit | undefined
+ /** Resolved client IP after proxy trust */
+ readonly #clientIp: string | undefined
+ /** Direct peer IP before proxy resolution */
+ readonly #directIp: string | undefined
+ /** Event emitter for observability signals */
+ readonly #emitEvent: Types.EventFn
+ /** Optional error middleware handler */
+ readonly #errorHandler: Types.ErrorMiddleware | null
+ /** Internal control surface for framework */
+ readonly #internal: Types.ContextInternal
+ /** Optional view rendering function */
+ readonly #renderer: Types.RenderFn | null
+ /** Accumulated response header values */
+ readonly #responseHeaders: Types.StringRecord = Object.create(null)
+ /** Accumulated Set-Cookie header values */
+ readonly #setCookies: string[] = []
+ /** Parsed request URL instance */
+ readonly #url: URL
+ /** Cached parsed request body data */
+ #bodyData: unknown = undefined
+ /** Format used to read request body */
+ #bodyFormat: Types.BodyFormat | null = null
+ /** Cached parsed cookie name value map */
+ #cookieMap: Types.StringRecord | undefined = undefined
+ /** Last framework error captured */
+ #frameworkError: Error | null = null
+ /** Cached frozen request reading helpers */
+ #getHelpers: Types.GetHelpers | undefined = undefined
+ /** Decoded route parameter map */
+ #params: Types.StringRecord = Object.create(null)
+ /** Underlying request instance */
+ #req: Request
+ /** Cached frozen response sending helpers */
+ #sendHelpers: Types.SendHelpers | undefined = undefined
+ /** Installed session controller instance */
+ #session: Types.SessionController | null = null
+ /** Cached frozen response setting helpers */
+ #setHelpers: Types.SetHelpers | undefined = undefined
+ /** Installed validated data controller */
+ #validated: Types.ValidatedController | null = null
+ /** Installed worker pool controller */
+ #worker: Types.WorkerController | null = null
/**
- * Create context for one request.
- * @description Binds request, URL, params, and optional error handler.
- * @param req - Incoming request
+ * Construct request context instance.
+ * @description Wires request, URL, IP, renderer, and event emitter.
+ * @param req - Incoming request instance
* @param url - Parsed request URL
- * @param params - Route path params
- * @param errorHandler - Optional custom error handler
+ * @param errorHandler - Optional error middleware handler
* @param clientIp - Resolved client IP address
- * @param directIp - Direct TCP peer IP address
- * @param emit - Optional router event emitter for observability
+ * @param directIp - Direct peer IP address
+ * @param renderer - Optional view rendering function
+ * @param emitEvent - Event emitter for observability
*/
constructor(
req: Request,
url: URL,
- params?: Types.StringRecord,
- errorHandler?: Types.ErrorHandler,
- clientIp?: string,
- directIp?: string,
- emit?: Types.EventEmit
+ errorHandler: Types.ErrorMiddleware | null,
+ clientIp: string | undefined,
+ directIp: string | undefined,
+ renderer: Types.RenderFn | null,
+ emitEvent: Types.EventFn
) {
- this.req = req
- this.parsedUrl = url
- this.routeParams = params === undefined ? Object.create(null) : Context.decodeParams(params)
- this.errorHandler = errorHandler
- this.clientIpValue = clientIp
- this.directIpValue = directIp ?? clientIp
- this.emit = emit
+ this.#req = req
+ this.#url = url
+ this.#errorHandler = errorHandler
+ this.#clientIp = clientIp
+ this.#directIp = directIp ?? clientIp
+ this.#renderer = renderer
+ this.#emitEvent = emitEvent
+ this.#internal = {
+ emitEvent: (event) => this.#emitEvent(event),
+ finalizeRaw: (response) => this.#finalizeRaw(response),
+ getFrameworkError: () => this.#frameworkError,
+ installSession: (controller) => this.#installSession(controller),
+ installValidated: (controller) => this.#installValidated(controller),
+ installWorker: (controller) => this.#installWorker(controller),
+ setParams: (params) => this.#setParams(params)
+ }
}
- /** Internal framework-only Context surface */
- get [InternalContext](): Types.ContextInternal {
- const readCookies = (): readonly string[] => this.responseCookies
- const readHeadersMap = (): Types.StringRecord => this.responseHeadersMap
- return {
- finalizeRaw: (response) => this.finalizeRaw(response),
- getFrameworkError: () => this.getFrameworkError(),
- replaceRequest: (req) => this.replaceRequest(req),
- setParams: (params) => this.setParams(params),
- setInternalState: (key, value) => this.setInternalState(key, value),
- emitEvent: (event) => this.emit?.(event),
- get responseCookies() {
- return readCookies()
- },
- get responseHeadersMap() {
- return readHeadersMap()
+ /** Frozen request reading helpers */
+ get get(): Types.GetHelpers {
+ if (this.#getHelpers === undefined) {
+ const helpers: Types.GetHelpers = {
+ ip: (options) => (options?.direct === true ? this.#directIp : this.#clientIp),
+ method: () => this.#req.method,
+ url: () => this.#url,
+ pathname: () => this.#url.pathname,
+ request: () => this.#req,
+ header: (key?: string) => this.#lookup(this.#req.headers, key),
+ cookie: (key?: string) => this.#lookupCookie(key),
+ query: (key?: string) => this.#lookup(this.#url.searchParams, key),
+ param: (key?: string) => this.#lookupParam(key),
+ body: () => this.#readBody() as Promise,
+ json: () => this.#read('json', (req) => req.json()) as Promise,
+ text: () => this.#read('text', (req) => req.text()),
+ formData: () => this.#read('form', (req) => req.formData()),
+ blob: () => this.#read('blob', (req) => req.blob()),
+ bytes: () => this.#read('bytes', (req) => req.bytes()),
+ session: () => this.#session?.state ?? null,
+ validated: () => this.#readValidated(),
+ worker: () => this.#readWorker()
+ } as Types.GetHelpers
+ this.#getHelpers = Object.freeze(helpers)
+ }
+ return this.#getHelpers
+ }
+
+ /** Frozen response setting helpers */
+ get set(): Types.SetHelpers {
+ if (this.#setHelpers === undefined) {
+ const helpers: Types.SetHelpers = {
+ header: (key, value) => {
+ this.#applyHeader(key, value)
+ return helpers
+ },
+ headers: (headers) => {
+ for (const key of Object.keys(headers)) {
+ this.#applyHeader(key, headers[key]!)
+ }
+ return helpers
+ },
+ cookie: (name, value, options) => {
+ this.#setCookies.push(Core.Cookie.serialize(name, value, options))
+ return helpers
+ },
+ session: (data) => this.#writeSession(data)
}
+ this.#setHelpers = Object.freeze(helpers)
}
+ return this.#setHelpers
}
- /** Direct TCP peer IP address */
- get directIp(): string | undefined {
- return this.directIpValue
- }
-
- /** Raw request Headers */
- get headers(): Headers {
- return this.req.headers
- }
-
- /** Resolved client IP address */
- get ip(): string | undefined {
- return this.clientIpValue
- }
-
- /** Request pathname from URL */
- get pathname(): string {
- return this.parsedUrl.pathname
- }
-
- /** Raw Request object */
- get request(): Request {
- return this.req
- }
-
- /** Send helpers for response building */
+ /** Frozen response sending helpers */
get send(): Types.SendHelpers {
- if (!this.sendHelpers) {
- this.sendHelpers = Core.Response.create(
- this.responseHeaders,
- this.setCookieValues,
- (url, status, extraHeaders) =>
+ if (this.#sendHelpers === undefined) {
+ const helpers: Types.SendHelpers = {
+ json: (data, options) =>
+ this.#build(Core.API.jsonStringify(data), 'application/json', options),
+ text: (text, options) => this.#build(text, 'text/plain; charset=utf-8', options),
+ html: (html, options) => this.#build(html, 'text/html; charset=utf-8', options),
+ custom: (body, options) => this.#build(body, null, options),
+ download: (body, filename, options) => {
+ const disposition = Core.Handler.contentDisposition(filename)
+ const headers = {
+ ...Core.Handler.toRecord(options?.headers),
+ 'Content-Disposition': disposition
+ }
+ return this.#build(body, Core.Constant.defaultContentType, { ...options, headers })
+ },
+ empty: (status) => this.#build(null, null, status === undefined ? undefined : { status }),
+ redirect: (url, status, options) =>
Core.Redirect.buildResponse(
- this.req.url,
- this.responseHeaders,
- this.setCookieValues,
+ this.#req.url,
+ this.#responseHeaders,
+ this.#setCookies,
url,
- status,
- extraHeaders
+ status ?? 302,
+ options?.headers
)
- )
+ }
+ this.#sendHelpers = Object.freeze(helpers)
}
- return this.sendHelpers
+ return this.#sendHelpers
}
- /** Shared mutable userland request state */
- get state(): Types.DataRecord {
- return this.requestState
- }
-
- /** Full request URL string */
- get url(): string {
- return this.req.url
+ /**
+ * Build error response for status.
+ * @description Uses error middleware when present otherwise default.
+ * @param statusCode - HTTP status code to send
+ * @param error - Caught error instance
+ * @returns Promise resolving to error response
+ */
+ async handleError(statusCode: number, error: Error): Promise {
+ this.#frameworkError = error
+ if (this.#errorHandler) {
+ return await Core.Handler.buildResponse(this, statusCode, error, this.#errorHandler)
+ }
+ return Core.Handler.errorResponse(this, statusCode)
}
- /** Read body as ArrayBuffer */
- async arrayBuffer(): Promise {
- return await this.readBody('arraybuffer', (req) => req.arrayBuffer())
+ /**
+ * Expose internal control surface.
+ * @description Returns framework only context internal handle.
+ * @param ctx - Context instance to unwrap
+ * @returns Internal control surface object
+ */
+ static internalOf(ctx: Context): Types.ContextInternal {
+ return ctx.#internal
}
- /** Read body as Blob */
- async blob(): Promise {
- return await this.readBody('blob', (req) => req.blob())
+ /**
+ * Render template into response.
+ * @description Requires configured view engine to render template.
+ * @param template - Template name to render
+ * @param data - View data for template
+ * @param options - Render options like status
+ * @returns Promise resolving to rendered response
+ * @throws When view engine is not configured
+ */
+ async render(
+ template: string,
+ data: Types.ViewData = {},
+ options: Types.RenderInit = {}
+ ): Promise {
+ if (this.#renderer === null) {
+ throw new Deno.errors.NotSupported(
+ 'View engine not configured, set views directory in RouterOptions'
+ )
+ }
+ return await this.#renderer(template, data, options)
}
- /** Read body by content type */
- async body(): Promise {
- if (this.bodyParsedAs !== null) {
- return this.bodyData
+ /**
+ * Apply a single response header.
+ * @description Validates header then stores or queues cookie.
+ * @param key - Header name to apply
+ * @param value - Header value to set
+ * @throws When header name or value is invalid
+ */
+ #applyHeader(key: string, value: string): void {
+ try {
+ new Core.API.Headers().set(key, value)
+ } catch {
+ throw Core.Handler.createStatusError(500, `Invalid response header "${key}"`)
}
- const mediaType = Context.parseMediaType(this.req.headers.get('content-type'))
- if (mediaType === 'application/json') {
- try {
- this.bodyData = await this.req.json()
- } catch (parseError) {
- Context.rethrowStatusError(parseError)
- this.bodyData = null
- }
- this.bodyParsedAs = 'json'
- } else if (
- mediaType === 'multipart/form-data' ||
- mediaType === 'application/x-www-form-urlencoded'
- ) {
- try {
- this.bodyData = await this.req.formData()
- } catch (parseError) {
- Context.rethrowStatusError(parseError)
- this.bodyData = null
- }
- this.bodyParsedAs = 'form'
+ if (key.toLowerCase() === 'set-cookie') {
+ this.#setCookies.push(value)
} else {
- try {
- this.bodyData = await this.req.text()
- } catch (parseError) {
- throw Context.toBodyError(parseError)
- }
- this.bodyParsedAs = 'text'
+ this.#responseHeaders[key] = value
}
- return this.bodyData
}
/**
- * Get cookie by key or all.
- * @description Parses Cookie header on first access.
- * @param key - Cookie name
- * @returns Cookie value or undefined
+ * Validate optional response status code.
+ * @description Allows null body statuses and 200 to 599.
+ * @param status - Status code to validate
+ * @throws When status is outside allowed range
*/
- cookie(): Types.StringRecord
- cookie(key: string): string | undefined
- cookie(key?: string): string | Types.StringRecord | undefined {
- if (this.cookieMap === undefined) {
- this.parseCookies()
+ #assertStatus(status?: number): void {
+ if (status === undefined) {
+ return
+ }
+ if (
+ !Number.isInteger(status) ||
+ ((status < 200 || status > 599) && !Core.Constant.nullBodyStatuses.has(status))
+ ) {
+ throw new Deno.errors.InvalidData(
+ `Response status must be an integer in the 200-599 range, got "${String(status)}"`
+ )
}
- return key ? this.cookieMap?.[key] : this.cookieMap
- }
-
- /** Read body as FormData */
- async formData(): Promise {
- return await this.readBody('form', (req) => req.formData())
}
/**
- * Get typed state value.
- * @description Type-safe alternative to `state[key] as T`.
- * @template T - Value type encoded in the key
- * @param key - Branded state key
- * @returns Typed value or undefined
+ * Build response with headers and cookies.
+ * @description Merges headers, content type, and Set-Cookie values.
+ * @param body - Response body or null
+ * @param contentType - Content type or null
+ * @param options - Optional response init values
+ * @returns Constructed response instance
*/
- getState(key: Types.StateKey): T | undefined {
- if (Core.Handler.reservedStateKeys.has(key)) {
- return this.frameworkState[key] as T | undefined
- }
- return this.requestState[key] as T | undefined
+ #build(body: BodyInit | null, contentType: string | null, options?: Types.SendInit): Response {
+ this.#assertStatus(options?.status)
+ const status = options?.status
+ const isNullBody = status !== undefined && Core.Constant.nullBodyStatuses.has(status)
+ const extra = Core.Handler.toRecord(options?.headers)
+ const headers: Types.StringRecord = contentType && !isNullBody
+ ? { ...this.#responseHeaders, 'Content-Type': contentType, ...extra }
+ : { ...this.#responseHeaders, ...extra }
+ const init: ResponseInit = options ? { ...options, headers } : { headers }
+ const finalBody = isNullBody ? null : body
+ const response = new Core.API.Response(finalBody, init)
+ Core.Handler.appendCookies(response.headers, this.#setCookies)
+ return response
}
/**
- * Build error response via handler.
- * @description Uses errorHandler if set else custom response.
- * @param statusCode - HTTP status code
- * @param error - Error instance
- * @returns Error response
+ * Merge pending headers into response.
+ * @description Adds missing headers and queued Set-Cookie values.
+ * @param response - Raw response to finalize
+ * @returns Same response with merged headers
*/
- async handleError(statusCode: number, error: Error): Promise {
- this.frameworkError = error
- if (this.errorHandler) {
- return await this.errorHandler(this, statusCode, error)
+ #finalizeRaw(response: Response): Response {
+ for (const headerKey of Object.keys(this.#responseHeaders)) {
+ if (!response.headers.has(headerKey)) {
+ response.headers.set(headerKey, this.#responseHeaders[headerKey]!)
+ }
}
- return Core.Handler.errorResponse(this, statusCode)
+ if (this.#setCookies.length > 0 && response.headers.get('Set-Cookie') === null) {
+ Core.Handler.appendCookies(response.headers, this.#setCookies)
+ }
+ return response
}
/**
- * Get header by name.
- * @description Parses headers on first access, keys lowercased.
- * @param key - Header name
- * @returns Header value or undefined
+ * Install session controller instance.
+ * @description Stores controller for session reads and writes.
+ * @param controller - Session controller to install
*/
- header(): Types.StringRecord
- header(key: string): string | undefined
- header(key?: string): string | Types.StringRecord | undefined {
- if (key) {
- return this.req.headers.get(key) ?? undefined
- }
- return Context.collectRecord(this.req.headers)
- }
-
- /** Read body as JSON */
- async json(): Promise {
- return await this.readBody('json', (req) => req.json())
+ #installSession(controller: Types.SessionController): void {
+ this.#session = controller
}
/**
- * Get single route param by key.
- * @description Returns one named param from route match.
- * @param key - Param name from pattern
- * @returns Param value or undefined
+ * Install validated data controller.
+ * @description Stores controller for validated data reads.
+ * @param controller - Validated controller to install
*/
- param(key: string): string | undefined {
- return this.routeParams[key]
- }
-
- /** Get all route path params */
- params(): Types.StringRecord {
- return { ...this.routeParams }
+ #installValidated(controller: Types.ValidatedController): void {
+ this.#validated = controller
}
/**
- * Get all values for query key.
- * @description Returns all query values for repeated key.
- * @param key - Query parameter name
- * @returns Array of values
+ * Install worker pool controller.
+ * @description Stores controller for worker task dispatch.
+ * @param controller - Worker controller to install
*/
- queries(key: string): string[] {
- return this.parsedUrl.searchParams.getAll(key)
+ #installWorker(controller: Types.WorkerController): void {
+ this.#worker = controller
}
/**
- * Get query param by key.
- * @description Parses search params on first access.
- * @param key - Query key
- * @returns Query value or undefined
+ * Look up entry value or record.
+ * @description Returns single value or full record map.
+ * @param entries - Iterable key value entries
+ * @param key - Optional key to look up
+ * @returns Value, record map, or undefined
*/
- query(): Types.StringRecord
- query(key: string): string | undefined
- query(key?: string): string | Types.StringRecord | undefined {
- if (key) {
- return this.parsedUrl.searchParams.get(key) ?? undefined
+ #lookup(
+ entries: Iterable,
+ key?: string
+ ): Types.StringRecord | string | undefined {
+ if (key !== undefined) {
+ for (const [entryKey, entryValue] of entries) {
+ if (entryKey === key) {
+ return entryValue
+ }
+ }
+ return undefined
}
- return Context.collectRecord(this.parsedUrl.searchParams)
+ return Context.collectRecord(entries)
}
/**
- * Redirect response to a URL.
- * @description Wraps `ctx.send.redirect` with same builder.
- * @param url - Target URL (relative same-origin or explicit absolute http(s))
- * @param status - Redirect status code, defaults to 302
- * @param options - Optional extra headers
- * @returns Redirect Response with Location header
+ * Look up cookie value or map.
+ * @description Parses cookies once then caches the result.
+ * @param key - Optional cookie name to read
+ * @returns Cookie value, full map, or undefined
*/
- redirect(
- url: string,
- status: Types.RedirectStatus = 302,
- options?: Types.RedirectInit
- ): Response {
- return this.send.redirect(url, status, options)
+ #lookupCookie(key?: string): Types.StringRecord | string | undefined {
+ if (this.#cookieMap === undefined) {
+ this.#cookieMap = this.#parseCookies()
+ }
+ return key !== undefined ? this.#cookieMap[key] : this.#cookieMap
}
/**
- * Render template and return HTML response.
- * @description Requires viewsDir set in Router, uses ctx.state.view.
- * @param templatePath - Path to .dve template relative to viewsDir
- * @param data - Data for template
- * @returns Response with rendered HTML
+ * Look up route parameter value.
+ * @description Returns single param or copied param map.
+ * @param key - Optional parameter name to read
+ * @returns Parameter value, copied map, or undefined
*/
- async render(templatePath: string, data: Types.DataRecord = {}): Promise {
- const renderedHtml = await this.requireViewEngine().render(templatePath, data)
- return this.send.html(renderedHtml)
+ #lookupParam(key?: string): Types.StringRecord | string | undefined {
+ return key !== undefined ? this.#params[key] : { ...this.#params }
}
/**
- * Set one response header.
- * @description Merges one header into response headers.
- * @param key - Header name
- * @param value - Header value
- * @returns this for chaining
+ * Parse cookies from request header.
+ * @description Splits cookie header into name value pairs.
+ * @returns Parsed cookie name value record
*/
- setHeader(key: string, value: string): this {
- Context.assertValidHeader(key, value)
- this.applyHeader(key, value)
- return this
+ #parseCookies(): Types.StringRecord {
+ const parsed: Types.StringRecord = Object.create(null)
+ const cookieHeader = this.#req.headers.get('cookie')
+ if (cookieHeader) {
+ for (const cookiePart of cookieHeader.split(';')) {
+ const trimmedPart = cookiePart.replace(Core.Constant.cookieTrimRegex, '')
+ const eqIndex = trimmedPart.indexOf('=')
+ if (eqIndex <= 0) {
+ continue
+ }
+ const cookieName = trimmedPart.slice(0, eqIndex).replace(Core.Constant.cookieTrimRegex, '')
+ if (cookieName.length > 0 && !Object.hasOwn(parsed, cookieName)) {
+ parsed[cookieName] = trimmedPart.slice(eqIndex + 1)
+ }
+ }
+ }
+ return parsed
}
/**
- * Set multiple response headers.
- * @description Merges headers into response headers.
- * @param headers - Key-value map of headers
- * @returns this for chaining
+ * Read request body in format.
+ * @description Caches body and blocks conflicting format reads.
+ * @param format - Body format to read
+ * @param reader - Reader producing body value
+ * @returns Promise resolving to body value
+ * @throws When body already read as another format
+ * @template R - Body value type returned
*/
- setHeaders(headers: Types.StringRecord): this {
- const entries = Object.entries(headers)
- for (const [key, value] of entries) {
- Context.assertValidHeader(key, value)
+ async #read(format: Types.BodyFormat, reader: (req: Request) => Promise): Promise {
+ if (this.#bodyFormat === format) {
+ return this.#bodyData as R
}
- for (const [key, value] of entries) {
- this.applyHeader(key, value)
+ if (this.#bodyFormat !== null) {
+ throw Core.Handler.createStatusError(
+ 409,
+ `Request body already read as ${this.#bodyFormat}`
+ )
+ }
+ try {
+ this.#bodyData = await reader(this.#req)
+ } catch (cause) {
+ throw Core.Handler.isStatusError(cause)
+ ? cause
+ : Core.Handler.createStatusError(400, 'Malformed or unreadable request body')
}
- return this
+ this.#bodyFormat = format
+ return this.#bodyData as R
}
/**
- * Set typed state value.
- * @description Type-safe alternative to `state[key] = value`.
- * @template T - Value type encoded in the key
- * @param key - Branded state key
- * @param value - Value matching the key's type
- * @throws {Types.StatusError} When the key is a reserved framework key
+ * Read body using content type.
+ * @description Chooses JSON, form, or text reader.
+ * @returns Promise resolving to parsed body
*/
- setState(key: Types.StateKey, value: T): void {
- if (Core.Handler.reservedStateKeys.has(key)) {
- throw Core.Handler.createStatusError(500, `State key "${key}" is reserved`)
+ #readBody(): Promise {
+ const mediaType = Context.parseMediaType(this.#req.headers.get('content-type'))
+ if (Context.isJsonMedia(mediaType)) {
+ return this.#read('json', (req) => req.json())
+ }
+ if (
+ mediaType === 'multipart/form-data' ||
+ mediaType === 'application/x-www-form-urlencoded'
+ ) {
+ return this.#read('form', (req) => req.formData())
}
- this.requestState[key] = value
+ return this.#read('text', (req) => req.text())
}
/**
- * Render template with streaming.
- * @description Requires viewsDir set in Router, validates before committing.
- * @param templatePath - Path to .dve template relative to viewsDir
- * @param data - Data for template
- * @returns Response with streaming HTML
+ * Read validated request data.
+ * @description Requires validate middleware to be registered.
+ * @returns Validated value of unknown type
+ * @throws When validate middleware is not registered
*/
- async streamRender(templatePath: string, data: Types.DataRecord = {}): Promise {
- const htmlStream = await this.requireViewEngine().streamRender(templatePath, data)
- return this.send.stream(htmlStream, undefined, 'text/html; charset=utf-8')
- }
-
- /** Read body as plain text */
- async text(): Promise {
- return await this.readBody('text', (req) => req.text())
- }
-
- /** All Set-Cookie header values */
- private get responseCookies(): readonly string[] {
- return this.setCookieValues
+ #readValidated(): unknown {
+ if (this.#validated === null) {
+ throw new Deno.errors.NotSupported(
+ 'Validated read requires the validate middleware, register it before reading validated data'
+ )
+ }
+ return this.#validated.value
}
- /** Snapshot copy of response headers */
- private get responseHeadersMap(): Types.StringRecord {
- return { ...this.responseHeaders }
+ /**
+ * Read worker pool controller.
+ * @description Requires configured worker pool to read.
+ * @returns Worker controller instance
+ * @throws When worker pool is not configured
+ */
+ #readWorker(): Types.WorkerController {
+ if (this.#worker === null) {
+ throw new Deno.errors.NotSupported(
+ 'Worker read requires a worker pool, configure RouterOptions worker before reading'
+ )
+ }
+ return this.#worker
}
/**
- * Route one header pair to its accumulator.
- * @description Appends Set-Cookie values, overwrites all other headers.
- * @param key - Validated header name
- * @param value - Validated header value
+ * Set decoded route parameters.
+ * @description Decodes percent encoded parameter values.
+ * @param params - Raw route parameter map
*/
- private applyHeader(key: string, value: string): void {
- if (key === 'Set-Cookie') {
- this.setCookieValues.push(value)
- } else {
- this.responseHeaders[key] = value
- }
+ #setParams(params: Types.StringRecord): void {
+ this.#params = Context.decodeParams(params)
}
/**
- * Validate a response header pair.
- * @description Delegates to Headers built-in so invalid input fails fast.
- * @param key - Header name
- * @param value - Header value
- * @throws {Types.StatusError} When name or value is not standards compliant
+ * Write session data through controller.
+ * @description Requires session middleware to be registered.
+ * @param data - Session data or null
+ * @returns Promise resolving when write completes
+ * @throws When session middleware is not registered
*/
- private static assertValidHeader(key: string, value: string): void {
- try {
- new Core.API.Headers().set(key, value)
- } catch {
- throw Core.Handler.createStatusError(500, `Invalid response header "${key}"`)
+ #writeSession(data: Types.SessionData | null): Promise {
+ if (this.#session === null) {
+ throw new Deno.errors.NotSupported(
+ 'Session write requires the session middleware, register it before writing session data'
+ )
}
+ return this.#session.write(data)
}
/**
- * Collect string entries into a null-proto record.
- * @description First occurrence wins, prototype-pollution safe via Object.hasOwn.
- * @param entries - Iterable of key/value string pairs
- * @returns Null-prototype record of first-seen values
+ * Collect entries into record map.
+ * @description Keeps first value for duplicate keys.
+ * @param entries - Iterable key value entries
+ * @returns Record map of first values
*/
private static collectRecord(
entries: Iterable
@@ -450,10 +521,10 @@ export class Context {
}
/**
- * Percent-decode route param values once.
- * @description Decodes each value once, raw fallback on malformed input.
- * @param params - Raw params from the router match
- * @returns New record with each value decoded once
+ * Decode percent encoded parameters.
+ * @description Falls back to raw value on decode failure.
+ * @param params - Raw route parameter map
+ * @returns Decoded parameter record map
*/
private static decodeParams(params: Types.StringRecord): Types.StringRecord {
const decoded: Types.StringRecord = Object.create(null)
@@ -473,65 +544,22 @@ export class Context {
}
/**
- * Apply accumulated headers to raw Response.
- * @description Merges middleware headers and cookies, existing values win.
- * @param response - The native Response returned by the handler
- * @returns The same Response with accumulated headers and cookies applied
+ * Check media type is JSON.
+ * @description Matches JSON, text JSON, and suffix types.
+ * @param mediaType - Media type to inspect
+ * @returns True when media type is JSON
*/
- private finalizeRaw(response: Response): Response {
- const headerKeys = Object.keys(this.responseHeaders)
- if (headerKeys.length > 0) {
- for (const headerKey of headerKeys) {
- if (!response.headers.has(headerKey)) {
- response.headers.set(headerKey, this.responseHeaders[headerKey]!)
- }
- }
- }
- if (this.setCookieValues.length > 0 && response.headers.get('Set-Cookie') === null) {
- Core.Handler.appendCookies(response.headers, this.setCookieValues)
- }
- return response
- }
-
- /** Get captured framework error */
- private getFrameworkError(): Error | null {
- return this.frameworkError
- }
-
- /** Throws if body already consumed */
- private guardBodyUse(): void {
- if (this.bodyParsedAs !== null) {
- throw new Deno.errors.BadResource('Request body already consumed')
- }
- }
-
- /** Parse cookies, first occurrence wins */
- private parseCookies(): void {
- const parsedCookies = Object.create(null) as Types.StringRecord
- const cookieHeader = this.req.headers.get('cookie')
- if (cookieHeader) {
- const trimRegex = Core.Constant.cookieTrimRegex
- for (const cookiePart of cookieHeader.split(';')) {
- const trimmedPart = cookiePart.replace(trimRegex, '')
- const eqIndex = trimmedPart.indexOf('=')
- if (eqIndex <= 0) {
- continue
- }
- const cookieName = trimmedPart.slice(0, eqIndex).replace(trimRegex, '')
- const cookieValue = trimmedPart.slice(eqIndex + 1)
- if (cookieName && !Object.hasOwn(parsedCookies, cookieName)) {
- parsedCookies[cookieName] = cookieValue
- }
- }
- }
- this.cookieMap = parsedCookies
+ private static isJsonMedia(mediaType: string): boolean {
+ return mediaType === 'application/json' ||
+ mediaType === 'text/json' ||
+ mediaType.endsWith('+json')
}
/**
- * Parse Content-Type to canonical media type.
- * @description Lowercases type, drops parameters after first semicolon.
- * @param contentType - Raw Content-Type header value or null
- * @returns Lowercased media type, empty string when absent
+ * Parse media type from header.
+ * @description Strips parameters and lowercases the type.
+ * @param contentType - Content type header value
+ * @returns Lowercased media type string
*/
private static parseMediaType(contentType: string | null): string {
if (!contentType) {
@@ -541,103 +569,6 @@ export class Context {
const typePart = semicolonIndex === -1 ? contentType : contentType.slice(0, semicolonIndex)
return typePart.trim().toLowerCase()
}
-
- /**
- * Read and cache the body in a single format.
- * @description Returns cached value or guards, reads, then caches.
- * @template R - Concrete body representation returned by the reader
- * @param format - Cache discriminant for the parsed representation
- * @param read - Single-use reader pulling the body off the request
- * @returns The parsed body value in the requested format
- * @throws {Types.StatusError} When the body was already consumed or unreadable
- */
- private async readBody(
- format: Types.BodyParsedFormat,
- read: (req: Request) => Promise
- ): Promise {
- if (this.bodyParsedAs === format) {
- return this.bodyData as R
- }
- this.guardBodyUse()
- try {
- this.bodyData = await read(this.req)
- } catch (parseError) {
- throw Context.toBodyError(parseError)
- }
- this.bodyParsedAs = format
- return this.bodyData as R
- }
-
- /**
- * Replace request and reset body state.
- * @description Used by body-limiting middleware to replace request.
- * @param req - New request to use
- */
- private replaceRequest(req: Request): void {
- this.req = req
- this.bodyData = undefined
- this.bodyParsedAs = null
- }
-
- /**
- * Resolve the configured view engine or fail.
- * @description Single guard for render paths requiring a view engine.
- * @returns The wired ViewEngine instance
- * @throws {Deno.errors.NotSupported} When no view engine is configured
- */
- private requireViewEngine(): Types.ViewEngine {
- const viewEngine = this.getState(Core.Handler.stateKeys.view)
- if (viewEngine === undefined) {
- throw new Deno.errors.NotSupported(
- 'View engine not configured, set viewsDir in RouterOptions'
- )
- }
- return viewEngine
- }
-
- /**
- * Re-throw errors carrying valid status.
- * @description Ensures lenient body parsing never swallows status errors.
- * @param parseError - Error thrown while reading or parsing the body
- */
- private static rethrowStatusError(parseError: unknown): void {
- if (Core.Handler.isErrorWithStatus(parseError)) {
- throw parseError
- }
- }
-
- /**
- * Set framework-wired state value.
- * @description Internal write path for reserved framework keys.
- * @template T - Value type encoded in the key
- * @param key - Branded reserved state key
- * @param value - Value matching the key's type
- */
- private setInternalState(key: Types.StateKey, value: T): void {
- this.frameworkState[key] = value
- }
-
- /**
- * Merge route params into context.
- * @description Percent-decodes incoming params, then merges with existing.
- * @param params - Params from router match
- */
- private setParams(params: Types.StringRecord): void {
- this.routeParams = { ...this.routeParams, ...Context.decodeParams(params) }
- }
-
- /**
- * Normalize body failure to client error.
- * @description Preserves existing status, else classifies as 400.
- * @param parseError - Error thrown while reading or parsing the body
- * @returns StatusError carrying the resolved status code
- */
- private static toBodyError(parseError: unknown): Types.StatusError {
- return Core.Handler.isErrorWithStatus(parseError)
- ? parseError
- : Core.Handler.createStatusError(400, 'Malformed or unreadable request body')
- }
}
-/** Freeze Context prototype methods */
Immutable.freeze(Context.prototype)
diff --git a/src/core/Response.ts b/src/core/Response.ts
deleted file mode 100644
index e0dcea7..0000000
--- a/src/core/Response.ts
+++ /dev/null
@@ -1,190 +0,0 @@
-import type * as Types from '@interfaces/index.ts'
-import * as Core from '@core/index.ts'
-
-/**
- * Factory for ctx.send response helpers.
- * @description Merges context headers with each response.
- */
-export class Response {
- /**
- * Create SendHelpers for headers and redirect.
- * @description Binds base headers and redirect builder to helpers.
- * @param responseHeaders - Base headers for every response
- * @param setCookieValues - Cookie values to append
- * @param buildRedirect - Function to build redirect Response
- * @returns SendHelpers for ctx.send
- */
- static create(
- responseHeaders: Types.StringRecord,
- setCookieValues: readonly string[],
- buildRedirect: Types.RedirectBuilder
- ): Types.SendHelpers {
- const mergedHeaders = (contentType: string, options?: ResponseInit) => {
- const extra = options?.headers ? Core.Handler.toRecord(options.headers) : undefined
- return extra
- ? { ...responseHeaders, 'Content-Type': contentType, ...extra }
- : { ...responseHeaders, 'Content-Type': contentType }
- }
- const toInit = (headers: Types.StringRecord, options?: ResponseInit): ResponseInit => {
- if (options?.status !== undefined) {
- const status = options.status
- if (
- !Number.isInteger(status) ||
- ((status < 200 || status > 599) && !Core.Constant.nullBodyStatuses.has(status))
- ) {
- throw new Deno.errors.InvalidData(
- `Response status must be an integer in the 200-599 range, got "${String(status)}"`
- )
- }
- }
- return options ? { ...options, headers } : { headers }
- }
- const isNullBodyStatus = (options?: ResponseInit): boolean =>
- options?.status !== undefined && Core.Constant.nullBodyStatuses.has(options.status)
- const bodyForStatus = (body: BodyInit | null, options?: ResponseInit): BodyInit | null =>
- isNullBodyStatus(options) ? null : body
- const applyCookies = (response: globalThis.Response): globalThis.Response => {
- Core.Handler.appendCookies(response.headers, setCookieValues)
- return response
- }
- return {
- custom(body: BodyInit | null, options?: ResponseInit): globalThis.Response {
- const extraRecord = options?.headers ? Core.Handler.toRecord(options.headers) : undefined
- const headers = extraRecord
- ? { ...responseHeaders, ...extraRecord }
- : { ...responseHeaders }
- return applyCookies(
- new Core.API.Response(bodyForStatus(body, options), toInit(headers, options))
- )
- },
- data(
- data: Uint8Array | string,
- filename: string,
- options?: ResponseInit,
- contentType = 'application/octet-stream'
- ): globalThis.Response {
- const encodedBody = typeof data === 'string' ? Core.Constant.encoder.encode(data) : data
- if (isNullBodyStatus(options)) {
- return applyCookies(
- new Core.API.Response(null, toInit(mergedHeaders(contentType, options), options))
- )
- }
- return applyCookies(
- new Core.API.Response(
- encodedBody as BodyInit,
- toInit(
- {
- ...mergedHeaders(contentType, options),
- 'Content-Disposition': Response.contentDisposition(filename),
- 'Content-Length': encodedBody.length.toString()
- },
- options
- )
- )
- )
- },
- async file(
- filePath: string,
- filename?: string,
- options?: ResponseInit
- ): Promise {
- let fsFile: Deno.FsFile | null = null
- try {
- fsFile = await Deno.open(filePath, { read: true })
- const fileInfo = await fsFile.stat()
- const downloadName = filename || filePath.split(/[\\/]/).pop() || 'download'
- if (isNullBodyStatus(options)) {
- fsFile.close()
- return applyCookies(
- new Core.API.Response(
- null,
- toInit(
- {
- ...mergedHeaders('application/octet-stream', options),
- 'Content-Disposition': Response.contentDisposition(downloadName)
- },
- options
- )
- )
- )
- }
- return applyCookies(
- new Core.API.Response(
- fsFile.readable,
- toInit(
- {
- ...mergedHeaders('application/octet-stream', options),
- 'Content-Disposition': Response.contentDisposition(downloadName),
- 'Content-Length': fileInfo.size.toString()
- },
- options
- )
- )
- )
- } catch (error) {
- fsFile?.close()
- const errorMessage = error instanceof Core.API.Error ? error.message : 'Unknown error'
- throw new Deno.errors.NotFound(
- `Failed to read file "${filePath}" because ${errorMessage}`
- )
- }
- },
- html: (html: string, options?: ResponseInit) =>
- applyCookies(
- new Core.API.Response(
- bodyForStatus(html, options),
- toInit(mergedHeaders('text/html; charset=utf-8', options), options)
- )
- ),
- json: (data: unknown, options?: ResponseInit) => {
- const init = toInit(mergedHeaders('application/json', options), options)
- return isNullBodyStatus(options)
- ? applyCookies(new Core.API.Response(null, init))
- : applyCookies(Core.API.Response.json(data, init))
- },
- redirect(
- url: string,
- status: Types.RedirectStatus = 302,
- options?: Types.RedirectInit
- ): globalThis.Response {
- return buildRedirect(url, status, options?.headers)
- },
- stream: (
- stream: ReadableStream,
- options?: ResponseInit,
- contentType = 'application/octet-stream'
- ) =>
- applyCookies(
- new Core.API.Response(
- bodyForStatus(stream, options),
- toInit(mergedHeaders(contentType, options), options)
- )
- ),
- text: (text: string, options?: ResponseInit) =>
- applyCookies(
- new Core.API.Response(
- bodyForStatus(text, options),
- toInit(mergedHeaders('text/plain; charset=utf-8', options), options)
- )
- )
- }
- }
-
- /**
- * Build Content-Disposition header value.
- * @description Sanitizes filename, emits ASCII fallback and UTF-8 parameter.
- * @param filename - Raw filename string
- * @returns Safe attachment header value
- */
- private static contentDisposition(filename: string): string {
- const basename = filename.replace(Core.Constant.sanitizeRegex, '')
- const asciiFallback = basename
- .replace(Core.Constant.nonAsciiGlobalRegex, '_')
- .replace(Core.Constant.escapeRegex, (ch) => (ch === '\\' ? '\\\\' : '\\"'))
- let headerValue = `attachment; filename="${asciiFallback}"`
- if (Core.Constant.nonAsciiRegex.test(basename)) {
- headerValue += `; filename*=UTF-8''${encodeURIComponent(basename)}`
- }
- return headerValue
- }
-}
diff --git a/src/interfaces/Core.ts b/src/interfaces/Core.ts
index 7d7fb31..e0b9b07 100644
--- a/src/interfaces/Core.ts
+++ b/src/interfaces/Core.ts
@@ -1,217 +1,572 @@
-import type * as Types from '@interfaces/index.ts'
import type * as Core from '@core/index.ts'
-
-/** Inclusive byte range for partial content. */
-export interface ByteRange {
- /** Inclusive end byte offset */
- readonly end: number
- /** Inclusive start byte offset */
- readonly start: number
-}
+import type * as Types from '@interfaces/index.ts'
+import type { ContractFn } from '@neabyte/typebox'
+import type DVE from '@neabyte/dve'
/**
- * Internal framework-only Context surface.
- * @description Members reachable cross-module via the InternalContext symbol.
+ * Internal context control surface.
+ * @description Framework only hooks for context state.
*/
export interface ContextInternal {
/**
- * Apply headers and cookies to Response.
- * @description Merges accumulated headers and cookies, existing values win.
- * @param response - Native Response to finalize
- * @returns Same Response with headers applied
+ * Emit observability event.
+ * @description Forwards event to context emitter.
+ * @param event - Event payload to emit
+ */
+ emitEvent(event: EventBase): void
+ /**
+ * Finalize raw response headers.
+ * @description Merges pending headers and cookies.
+ * @param response - Raw response to finalize
+ * @returns Same response with merged headers
*/
finalizeRaw(response: globalThis.Response): globalThis.Response
/**
* Read captured framework error.
- * @description Returns error set by handleError, null when none.
- * @returns Framework Error or null
+ * @description Returns last error or null.
+ * @returns Framework error or null
*/
getFrameworkError(): Error | null
/**
- * Replace request and reset body state.
- * @description Swaps the request and clears parsed body state.
- * @param req - New request to use
+ * Install session controller.
+ * @description Enables session reads and writes.
+ * @param controller - Session controller to install
*/
- replaceRequest(req: Request): void
+ installSession(controller: Types.SessionController): void
/**
- * Merge percent-decoded route params.
- * @description Spread-merges decoded params into existing route params.
- * @param params - Params from the router match
+ * Install validated data controller.
+ * @description Enables validated data reads.
+ * @param controller - Validated controller to install
*/
- setParams(params: StringRecord): void
+ installValidated(controller: ValidatedController): void
/**
- * Write reserved framework state key.
- * @description Internal write path for framework-wired keys.
- * @template T - Value type encoded in the key
- * @param key - Branded reserved state key
- * @param value - Value matching the key type
+ * Install worker pool controller.
+ * @description Enables worker task dispatch.
+ * @param controller - Worker controller to install
*/
- setInternalState(key: StateKey, value: T): void
+ installWorker(controller: WorkerController): void
/**
- * Emit lifecycle event on router bus.
- * @description No-op when the router has no emitter wired.
- * @param event - Lifecycle event to broadcast
+ * Set decoded route parameters.
+ * @description Stores parameters for context reads.
+ * @param params - Route parameter record
*/
- emitEvent(event: Types.EventBase): void
- /** Snapshot of accumulated Set-Cookie values */
- readonly responseCookies: readonly string[]
- /** Snapshot copy of accumulated response headers */
- readonly responseHeadersMap: StringRecord
+ setParams(params: StringRecord): void
+}
+
+/**
+ * Cookie attribute initialization options.
+ * @description Configures cookie scope and flags.
+ */
+export interface CookieInit {
+ /** Cookie domain scope */
+ domain?: string
+ /** Cookie expiry date or timestamp */
+ expires?: Date | number
+ /** Mark cookie as HTTP only */
+ httpOnly?: boolean
+ /** Cookie max age in seconds */
+ maxAge?: number
+ /** Cookie path scope */
+ path?: string
+ /** Cookie SameSite policy */
+ sameSite?: SameSitePolicy
+ /** Mark cookie as secure */
+ secure?: boolean
}
-/** Error details for error middleware. */
+/**
+ * Error information for handlers.
+ * @description Carries error, request, and status data.
+ */
export interface ErrorInfo {
/** Caught error instance */
readonly error: Error
- /** HTTP method of failed request */
+ /** Request HTTP method */
readonly method: string
- /** URL pathname of failed request */
+ /** Request path name */
readonly pathname: string
- /** HTTP status code for response */
+ /** HTTP status code */
readonly statusCode: number
- /** Full request URL string */
+ /** Full request URL */
readonly url: string
}
/**
- * Builds error response from status.
- * @description Constructs HTTP error response using context and middleware.
+ * Request reading helper methods.
+ * @description Reads method, URL, headers, and body.
*/
-export interface ErrorResponseBuilder {
+export interface GetHelpers {
+ /**
+ * Read client IP address.
+ * @description Returns direct peer when option set.
+ * @param options - Optional direct IP flag
+ * @returns Client IP or undefined
+ */
+ ip(options?: IpDirectOption): string | undefined
+ /**
+ * Read request HTTP method.
+ * @returns Request method string
+ */
+ method(): string
+ /**
+ * Read parsed request URL.
+ * @returns Request URL instance
+ */
+ url(): URL
+ /**
+ * Read request path name.
+ * @returns Request path string
+ */
+ pathname(): string
/**
- * Build error response.
- * @param ctx - Request context instance
- * @param statusCode - HTTP status code to send
- * @param error - Caught error instance
- * @param errorMiddleware - Optional error middleware handler
- * @returns Promise resolving to error response
+ * Read underlying request instance.
+ * @returns Request instance
*/
- build(
- ctx: Core.Context,
- statusCode: number,
- error: Error,
- errorMiddleware: ErrorMiddleware | null
- ): Promise
+ request(): Request
+ /** Read request header value or map */
+ header: RecordAccessor
+ /** Read request cookie value or map */
+ cookie: RecordAccessor
+ /** Read query parameter value or map */
+ query: RecordAccessor
+ /** Read route parameter value or map */
+ param: RecordAccessor
+ /**
+ * Read request body by type.
+ * @description Chooses reader from content type.
+ * @returns Promise resolving to body value
+ * @template T - Body value type
+ */
+ body(): Promise
+ /**
+ * Read request body as JSON.
+ * @returns Promise resolving to parsed JSON
+ * @template T - JSON value type
+ */
+ json(): Promise
+ /**
+ * Read request body as text.
+ * @returns Promise resolving to body text
+ */
+ text(): Promise
+ /**
+ * Read request body as form data.
+ * @returns Promise resolving to form data
+ */
+ formData(): Promise
+ /**
+ * Read request body as blob.
+ * @returns Promise resolving to body blob
+ */
+ blob(): Promise
+ /**
+ * Read request body as bytes.
+ * @returns Promise resolving to byte array
+ */
+ bytes(): Promise
+ /**
+ * Read current session data.
+ * @returns Session data or null
+ */
+ session(): Types.SessionData | null
+ /**
+ * Read validated request data.
+ * @description Requires validate middleware registration.
+ * @returns Validated data map
+ * @template SchemaType - Validation schema type
+ */
+ validated(): ValidatedMap
+ /**
+ * Read worker pool controller.
+ * @returns Worker controller instance
+ */
+ worker(): WorkerController
+}
+
+/**
+ * Direct IP read option.
+ * @description Selects direct peer over resolved IP.
+ */
+export interface IpDirectOption {
+ /** Read direct peer IP when true */
+ direct?: boolean
}
-/** Parsed IP address value with version. */
+/**
+ * Parsed IP value and version.
+ * @description Holds numeric value and protocol version.
+ */
export interface ParsedIp {
- /** Numeric address value */
+ /** Numeric IP address value */
readonly value: bigint
- /** Address version, 4 or 6 */
+ /** IP protocol version */
readonly version: 4 | 6
}
-/** Structured error problem details payload. */
+/**
+ * RFC problem details payload.
+ * @description Describes error type, title, and status.
+ */
export interface ProblemDetails {
- /** Problem type URI reference */
+ /** Problem type URI */
readonly type: string
- /** Short human-readable problem summary */
+ /** Short problem title */
readonly title: string
- /** HTTP status code for problem */
+ /** HTTP status code */
readonly status: number
- /** Optional URI reference of occurrence */
+ /** Request instance path */
readonly instance?: string
- /** Optional list of validation reasons */
+ /** Detailed error messages */
readonly errors?: readonly string[]
}
/**
- * Response helpers on context.
- * @description Provides typed methods for common response formats.
+ * Template render initialization options.
+ * @description Sets response status and stream flag.
+ */
+export interface RenderInit {
+ /** Response HTTP status code */
+ status?: HttpStatusCode
+ /** Stream rendered output when true */
+ stream?: boolean
+}
+
+/**
+ * Router constructor options.
+ * @description Configures routes, views, and limits.
+ */
+export interface RouterOptions {
+ /** Route loading options */
+ routes?: RoutesOptions
+ /** View rendering options */
+ views?: ViewsOptions
+ /** Enable hot reload watching */
+ hotReload?: boolean
+ /** Maximum request URL length */
+ maxUrlLength?: number
+ /** Request timeout in milliseconds */
+ timeoutMs?: number
+ /** Trusted proxy configuration */
+ trustProxy?: TrustProxyConfig
+ /** Worker pool configuration */
+ worker?: WorkerPoolOptions
+}
+
+/**
+ * Route loading options.
+ * @description Sets routes directory and parameter limit.
+ */
+export interface RoutesOptions {
+ /** Routes directory path */
+ directory?: string
+ /** Maximum route parameter length */
+ maxParamLength?: number
+}
+
+/**
+ * Response sending helper methods.
+ * @description Builds JSON, text, HTML, and redirects.
*/
export interface SendHelpers {
- /** Build custom response with body */
- readonly custom: ResponseFn<[body: BodyInit | null]>
- /** Build binary data download response */
- readonly data: ResponseFn<[data: Uint8Array | string, filename: string], [contentType?: string]>
- /** Serve file from filesystem path */
- readonly file: ResponseFn<[filePath: string, filename?: string], [], Promise>
- /** Build HTML content response */
- readonly html: ResponseFn<[html: string]>
- /** Build JSON serialized response */
- readonly json: ResponseFn<[data: unknown]>
- /** Build redirect response to URL */
- readonly redirect: (url: string, status?: RedirectStatus, options?: RedirectInit) => Response
- /** Build streaming response with ReadableStream */
- readonly stream: ResponseFn<[stream: ReadableStream], [contentType?: string]>
- /** Build plain text response */
- readonly text: ResponseFn<[text: string]>
+ /**
+ * Send JSON response body.
+ * @description Serializes data as JSON.
+ * @param data - Data to serialize
+ * @param options - Optional response init
+ * @returns JSON response instance
+ * @template T - Data value type
+ */
+ json(data: T, options?: SendInit): Response
+ /**
+ * Send plain text response.
+ * @param text - Text body to send
+ * @param options - Optional response init
+ * @returns Text response instance
+ */
+ text(text: string, options?: SendInit): Response
+ /**
+ * Send HTML response body.
+ * @param html - HTML body to send
+ * @param options - Optional response init
+ * @returns HTML response instance
+ */
+ html(html: string, options?: SendInit): Response
+ /**
+ * Send custom response body.
+ * @param body - Response body or null
+ * @param options - Optional response init
+ * @returns Custom response instance
+ */
+ custom(body: BodyInit | null, options?: SendInit): Response
+ /**
+ * Send file download response.
+ * @description Adds content disposition header.
+ * @param body - Download body content
+ * @param filename - Suggested download filename
+ * @param options - Optional response init
+ * @returns Download response instance
+ */
+ download(body: DownloadBody, filename: string, options?: SendInit): Response
+ /**
+ * Send empty response body.
+ * @param status - Optional HTTP status code
+ * @returns Empty response instance
+ */
+ empty(status?: HttpStatusCode): Response
+ /**
+ * Send redirect response.
+ * @description Validates and resolves location.
+ * @param url - Redirect target location
+ * @param status - Optional redirect status
+ * @param options - Optional redirect init
+ * @returns Redirect response instance
+ */
+ redirect(url: string, status?: RedirectStatus, options?: RedirectInit): Response
}
-/** Static file serving options. */
+/**
+ * Static file serving options.
+ * @description Sets path, ETag, and cache control.
+ */
export interface ServeOptions {
- /** Cache-Control max-age in seconds */
- readonly cacheControl?: number
- /** Enable ETag header generation */
- readonly etag?: boolean
/** Filesystem path to static directory */
- readonly path: string
+ path: string
+ /** Enable ETag header generation */
+ etag?: boolean
+ /** Cache-Control max age seconds */
+ cacheControl?: number
}
/**
- * Serves static files from path.
- * @description Handles static file requests using serve options.
+ * Response setting helper methods.
+ * @description Sets headers, cookies, and session.
*/
-export interface StaticHandler {
+export interface SetHelpers {
+ /**
+ * Set single response header.
+ * @param key - Header name to set
+ * @param value - Header value to set
+ * @returns Same helpers for chaining
+ */
+ header(key: string, value: string): SetHelpers
+ /**
+ * Set multiple response headers.
+ * @param headers - Header name value record
+ * @returns Same helpers for chaining
+ */
+ headers(headers: StringRecord): SetHelpers
/**
- * Serve static file response.
- * @param ctx - Request context instance
- * @param options - Static file serving options
- * @param urlPath - URL path to resolve
- * @returns Promise resolving to file response
+ * Set response cookie value.
+ * @param name - Cookie name to set
+ * @param value - Cookie value to set
+ * @param options - Optional cookie attributes
+ * @returns Same helpers for chaining
*/
- serve(ctx: Core.Context, options: ServeOptions, urlPath: string): Promise
+ cookie(name: string, value: string, options?: CookieInit): SetHelpers
+ /**
+ * Write session data to cookie.
+ * @param data - Session data or null
+ * @returns Promise resolving when write completes
+ */
+ session(data: Types.SessionData | null): Promise
}
-/** Worker message payload data. */
+/**
+ * Validated data controller.
+ * @description Exposes frozen validated value.
+ */
+export interface ValidatedController {
+ /** Frozen validated value */
+ readonly value: ValidatedValue
+}
+
+/**
+ * View rendering options.
+ * @description Sets views directory and render limits.
+ */
+export interface ViewsOptions {
+ /** Views directory path */
+ directory?: string
+ /** Maximum loop iterations per block */
+ maxIterations?: number
+ /** Maximum body executions per render */
+ maxRenderIterations?: number
+ /** Maximum output characters per render */
+ maxOutputSize?: number
+ /** Maximum template size in characters */
+ maxTemplateSize?: number
+}
+
+/**
+ * Worker pool task controller.
+ * @description Dispatches payloads to worker pool.
+ */
+export interface WorkerController {
+ /**
+ * Run task on worker pool.
+ * @param payload - Task payload to dispatch
+ * @returns Promise resolving to task result
+ * @template T - Task result type
+ */
+ run(payload: unknown): Promise
+}
+
+/**
+ * Worker response message data.
+ * @description Carries error flag and message.
+ */
export interface WorkerMessageData {
- /** True when message indicates error */
+ /** Error flag set on failure */
readonly error?: boolean
- /** Human-readable message text */
+ /** Error message when failed */
readonly message?: string
}
-/** Worker pool creation options. */
+/**
+ * Worker pool configuration options.
+ * @description Sets script, size, and queue limits.
+ */
export interface WorkerPoolOptions {
- /** Optional lifecycle event emitter */
- readonly emit?: Types.EventEmit
- /** Maximum pending tasks before fast-rejecting */
+ /** Maximum pending task count */
readonly maxQueueDepth?: number
- /** Maximum projected queue wait in ms */
+ /** Maximum projected wait milliseconds */
readonly maxQueueWaitMs?: number
- /** Number of workers in pool */
+ /** Worker pool size */
readonly poolSize?: number
- /** URL to worker script module */
+ /** Worker script URL */
readonly scriptURL: string
- /** Per-task timeout in milliseconds */
+ /** Per task timeout milliseconds */
readonly taskTimeoutMs?: number
}
+/** Supported request body read format */
+export type BodyFormat = 'blob' | 'bytes' | 'form' | 'json' | 'text'
+
+/** Inclusive byte range start and end */
+export type ByteRange = { readonly start: number; readonly end: number }
+
+/** Compiled template result from DVE */
+export type CompileResult = ReturnType
+
/**
- * Handle to run worker tasks.
- * @description Dispatches payloads to pooled worker threads.
+ * Context bound handler function.
+ * @description Receives context plus extra arguments.
+ * @template Args - Extra argument tuple type
+ * @template R - Handler return value type
*/
-export interface WorkerRunHandle {
- /**
- * Run task on worker.
- * @template T - Expected return type
- * @param payload - Data to send to worker
- * @returns Promise resolving to worker result
- */
- run(payload: unknown): Promise
+export type ContextFn = (
+ ctx: Core.Context,
+ ...args: Args
+) => MaybeAsync
+
+/** Download response body source type */
+export type DownloadBody = ReadableStream | BufferSource | string
+
+/** Error handling middleware function */
+export type ErrorMiddleware = ContextFn<[info: ErrorInfo], Response | null>
+
+/** Union of all lifecycle events */
+export type EventBase = {
+ [Kind in keyof EventSchemaMap]: LifecycleEvent
+}[keyof EventSchemaMap]
+
+/**
+ * Lifecycle event by kind.
+ * @description Extracts event matching given kind.
+ * @template Kind - Event kind discriminator
+ */
+export type EventByKind = Extract
+
+/** Event channel internal or external */
+export type EventChannel = 'internal' | 'external'
+
+/** Event metadata carrying an error */
+export type EventErrorMeta = { error: Error }
+
+/**
+ * Event listener callback function.
+ * @description Receives a lifecycle event.
+ * @param event - Lifecycle event payload
+ */
+export type EventFn = (event: EventBase) => void
+
+/** Union of all event kinds */
+export type EventKind = keyof EventSchemaMap
+
+/** Request event metadata with metrics */
+export type EventRequestMeta = RequestMetrics & {
+ method: string
+ statusCode: number
+ url: string
+ durationMs: number
+ error?: Error
+}
+
+/** Route event metadata path and pattern */
+export type EventRouteMeta = { routePath: string; pattern: string }
+
+/** Event kind to metadata schema map */
+export type EventSchemaMap = {
+ 'server:started': { port: number; hostname: string }
+ 'server:stopped': Record
+ 'route:added': EventRouteMeta
+ 'route:updated': EventRouteMeta
+ 'route:removed': EventRouteMeta
+ 'route:ignored': { routePath: string; reason: string }
+ 'route:failed': { routePath: string } & EventErrorMeta
+ 'view:compiled': EventViewMeta
+ 'view:rendered': EventViewMeta
+ 'view:invalidated': { paths: readonly string[] }
+ 'view:failed': { path: string } & EventErrorMeta
+ 'session:invalid': { cookieName: string; reason: SessionInvalidReason }
+ 'csrf:failed': { rule: CsrfRuleName } & EventErrorMeta
+ 'cors:blocked': { origin: string }
+ 'auth:failed': { reason: AuthFailReason }
+ 'ip:denied': { ip: string }
+ 'validate:failed': { source: ValidationSource; reasons: readonly string[] }
+ 'body:rejected': { limit: number; declared: number | null }
+ 'websocket:rejected': { reason: WebSocketRejectReason }
+ 'static:missing': { path: string }
+ 'process:failed': { origin: ProcessErrorOrigin } & EventErrorMeta
+ 'worker:crashed': EventWorkerMeta & EventErrorMeta
+ 'worker:rejected': { reason: WorkerRejectReason; queueDepth: number; maxQueueDepth: number }
+ 'worker:respawned': EventWorkerMeta
+ 'worker:timeout': { timeoutMs: number } & EventWorkerMeta & EventErrorMeta
+ 'request:completed': EventRequestMeta
+ 'request:failed': EventRequestMeta
}
-/** Body format the request parsed. */
-export type BodyParsedFormat = 'arraybuffer' | 'blob' | 'form' | 'json' | 'text'
+/** View event metadata path and duration */
+export type EventViewMeta = { path: string; durationMs: number }
+
+/** Worker event metadata worker index */
+export type EventWorkerMeta = { workerIndex: number }
+
+/** Extracted status code and error pair */
+export type ExtractedError = Pick
+
+/** Supported HTTP request method names */
+export type HttpMethod = 'DELETE' | 'GET' | 'HEAD' | 'OPTIONS' | 'PATCH' | 'POST' | 'PUT'
-/** 4xx client error status codes. */
-export type ClientErrorCode =
+/** Supported HTTP response status codes */
+export type HttpStatusCode =
+ | 200
+ | 201
+ | 202
+ | 204
+ | 206
+ | 301
+ | 302
+ | 303
+ | 304
+ | 307
+ | 308
| 400
| 401
| 403
| 404
| 405
+ | 406
| 408
| 409
| 410
@@ -219,129 +574,214 @@ export type ClientErrorCode =
| 414
| 415
| 422
+ | 426
| 429
+ | 500
+ | 501
+ | 502
+ | 503
+ | 504
/**
- * Context-receiving function type.
- * @description Generic for handlers that take context and return R.
- * @template Args - Additional argument types after context
- * @template R - Return type wrapped in MaybeAsync
+ * IP matcher predicate function.
+ * @description Tests whether IP matches a rule.
+ * @param ip - IP address to test
+ * @returns True when IP matches
*/
-export type ContextFn = (
- ctx: Core.Context,
- ...args: Args
-) => MaybeAsync
+export type IpMatcher = (ip: string) => boolean
-/** Generic string-keyed data record. */
-export type DataRecord = Record
+/**
+ * Lifecycle event payload shape.
+ * @description Holds channel, kind, metadata, and timestamp.
+ * @template Kind - Event kind discriminator
+ * @template Metadata - Event metadata shape
+ */
+export type LifecycleEvent = {
+ /** Event channel internal or external */
+ readonly type: EventChannel
+ /** Event kind discriminator */
+ readonly kind: Kind
+ /** Frozen event metadata */
+ readonly metadata: Readonly
+ /** Event creation timestamp */
+ readonly timestamp: number
+}
+
+/**
+ * Value or promise of value.
+ * @description Wraps synchronous or async result.
+ * @template T - Wrapped value type
+ */
+export type MaybeAsync = T | Promise
/**
- * Handler for route error responses.
- * @description Produces response from context, status, and error.
+ * Request middleware function.
+ * @description Receives context and next continuation.
+ * @param ctx - Request context instance
+ * @param next - Next middleware continuation
+ * @returns Response or undefined promise
*/
-export type ErrorHandler = ContextFn<[statusCode: number, error: Error], Response>
+export type MiddlewareFn = (ctx: Core.Context, next: NextFn) => ReturnType
/**
- * Custom handler before error response.
- * @description Intercepts errors before default error response is built.
+ * Middleware next continuation function.
+ * @description Invokes the next middleware in chain.
+ * @returns Response or undefined promise
*/
-export type ErrorMiddleware = ContextFn<[error: ErrorInfo], Response | null>
+export type NextFn = () => Promise
-/** Extracted status code and error. */
-export type ExtractedError = Pick
+/** Process error origin discriminator */
+export type ProcessErrorOrigin =
+ | 'process:exit'
+ | 'process:signal'
+ | 'uncaughterror'
+ | 'unhandledrejection'
-/** HTTP method literal union. */
-export type HttpMethod = 'DELETE' | 'GET' | 'HEAD' | 'OPTIONS' | 'PATCH' | 'POST' | 'PUT'
+/** Global object exposing optional process */
+export type ProcessGlobal = { process?: Record }
-/** HTTP status code branded type. */
-export type HttpStatusCode = ClientErrorCode | ServerErrorCode
+/**
+ * Record value and key accessor.
+ * @description Returns full record or single value.
+ */
+export type RecordAccessor = {
+ (): StringRecord
+ (key: string): string | undefined
+}
-/** Matcher predicate for an IP string. */
-export type IpMatcher = (ip: string) => boolean
+/** Redirect response init headers only */
+export type RedirectInit = Pick
+
+/** Allowed HTTP redirect status codes */
+export type RedirectStatus = 301 | 302 | 303 | 307 | 308
/**
- * Sync or async value wrapper.
- * @template T - Wrapped value type
+ * View render function signature.
+ * @description Renders template with data and options.
+ * @param template - Template name to render
+ * @param data - View data for template
+ * @param options - Render options like status
+ * @returns Promise resolving to rendered response
*/
-export type MaybeAsync = T | Promise
+export type RenderFn = (template: string, data: ViewData, options: RenderInit) => Promise
+
+/** Resolved rendering options from router */
+export type RenderingOptions = NonNullable['views']>
+
+/** Optional request metrics for events */
+export type RequestMetrics = {
+ ip?: string
+ route?: string
+ serverAddress?: string
+ serverPort?: number
+ userAgent?: string
+ requestSize?: number
+ responseSize?: number
+}
+
+/** Resolved file info and path */
+export type ResolvedFile = { readonly fileInfo: Deno.FileInfo; readonly filePath: string }
+
+/** Route handler returning a response */
+export type RouteHandler = ContextFn<[], Response>
+
+/** Imported route module export record */
+export type RouteModule = Record
/**
- * Callback that builds redirect response.
- * @description Constructs redirect Response with status and headers.
- * @param url - Target redirect URL
- * @param status - HTTP redirect status code
- * @param extraHeaders - Additional headers to include
- * @returns Redirect Response instance
+ * Dynamic module import function.
+ * @description Imports module by specifier string.
+ * @param specifier - Module specifier to import
+ * @returns Promise resolving to route module
*/
-export type RedirectBuilder = (
- url: string,
- status: RedirectStatus,
- extraHeaders?: HeadersInit
-) => Response
+export type RuntimeImport = (specifier: string) => Promise
-/** Optional headers for redirect init. */
-export type RedirectInit = Pick
+/** Cookie SameSite policy value */
+export type SameSitePolicy = 'Lax' | 'None' | 'Strict'
-/** Valid HTTP redirect status codes. */
-export type RedirectStatus = 301 | 302 | 303 | 307 | 308
+/** Response send init without status override */
+export type SendInit = Omit & { status?: HttpStatusCode }
+
+/** Reason a session was rejected */
+export type SessionInvalidReason = 'expired' | 'malformed' | 'tampered'
+
+/** CSRF rule that threw during evaluation */
+export type CsrfRuleName = 'origin' | 'secFetchSite'
+
+/** Reason basic auth rejected credentials */
+export type AuthFailReason = 'missing' | 'malformed' | 'invalid'
+
+/** Reason a websocket handshake was rejected */
+export type WebSocketRejectReason = 'origin' | 'version' | 'malformed'
/**
- * Response factory from payload args.
- * @description Appends optional ResponseInit and trailing args to payload.
- * @template Args - Leading payload argument tuple
- * @template Tail - Optional trailing argument tuple after options
- * @template R - Response return wrapper, sync or promised
+ * Validation source reader function.
+ * @description Reads a value from request helpers.
+ * @param get - Request reading helpers
+ * @returns Source value or promise
*/
-export type ResponseFn<
- Args extends readonly unknown[] = [],
- Tail extends readonly unknown[] = [],
- R extends Response | Promise = Response
-> = (...args: [...Args, options?: ResponseInit, ...rest: Tail]) => R
+export type SourceReader = (get: GetHelpers) => Promise | unknown
-/** 5xx server error status codes. */
-export type ServerErrorCode = 500 | 501 | 502 | 503 | 504
+/** Source reader map by validation source */
+export type SourceReaders = Readonly>
/**
- * Branded key for state access.
- * @template T - Value type stored under key
+ * Static file serving function.
+ * @description Serves response for a URL path.
+ * @param ctx - Request context instance
+ * @param urlPath - URL path relative to mount
+ * @returns Response or promise of response
*/
-export type StateKey = string & { readonly __stateValue: T }
+export type StaticFn = (ctx: Core.Context, urlPath: string) => MaybeAsync
/**
- * Carrier of an HTTP status code.
- * @description Single atom for status-bearing values, widen via S.
- * @template S - Confidence of the statusCode value
+ * Object carrying an HTTP status.
+ * @description Holds a readonly status code value.
+ * @template S - Status code value type
*/
export type StatusCarrier = { readonly statusCode: S }
-/** Error-like object with unknown statusCode property. */
-export type StatusCodeCarrier = StatusCarrier
-
-/** Error with attached HTTP status code. */
-export type StatusError = Error & { statusCode: number }
+/** Error carrying an HTTP status code */
+export type StatusError = Error & StatusCarrier
-/** String key-value tuple pair. */
+/** Tuple of two string values */
export type StringPair = [string, string]
-/** String-to-string key-value record. */
+/** Record of string keys to strings */
export type StringRecord = Record
+/** Trusted proxy rules or matcher */
+export type TrustProxyConfig = readonly string[] | IpMatcher
+
/**
- * Widened tag carrier for fail-closed reads.
- * @description Readonly record exposing one string-typed discriminant key.
- * @template K - Discriminant property name
+ * Validated output map by schema.
+ * @description Maps schema keys to validated outputs.
+ * @template SchemaType - Validation schema type
*/
-export type TagCarrier = { readonly [P in K]: string }
+export type ValidatedMap = {
+ readonly [Key in keyof SchemaType]: SchemaType[Key] extends ContractFn
+ ? ValidatedOutput
+ : never
+}
/**
- * Discriminated-union member with tag.
- * @description Joins a readonly tag literal with payload shape.
- * @template Tag - Discriminant property name
- * @template K - Literal value of the discriminant
- * @template Shape - Payload properties for the member
+ * Validated output of a contract.
+ * @description Awaited return type of contract function.
+ * @template ContractType - Contract function type
*/
-export type TaggedVariant =
- & {
- readonly [P in Tag]: K
- }
- & Readonly
+export type ValidatedOutput = Awaited>
+
+/** Frozen validated value record */
+export type ValidatedValue = Readonly>
+
+/** Validation schema by request source */
+export type ValidationSchema = Partial>
+
+/** Request source for validation */
+export type ValidationSource = 'body' | 'cookies' | 'headers' | 'query'
+
+/** View template data record */
+export type ViewData = Record
+
+/** Reason a worker task was rejected */
+export type WorkerRejectReason = 'queue-depth' | 'queue-wait'
diff --git a/tests/core/Context.test.ts b/tests/core/Context.test.ts
index a50d5c6..9756f96 100644
--- a/tests/core/Context.test.ts
+++ b/tests/core/Context.test.ts
@@ -1,1054 +1,183 @@
import { assertEquals } from '@std/assert'
import * as Core from '@core/index.ts'
+import Helper from '@tests/helper.ts'
-function createTestContext(
- url = 'http://localhost/',
- routeParams: Record = {},
- requestInit?: RequestInit
-): Core.Context {
- const request = new Request(url, requestInit)
- return new Core.Context(request, new URL(url), routeParams)
-}
-
-Deno.test('Context#arrayBuffer reads body as ArrayBuffer', async () => {
- const ctx = createTestContext('http://localhost/', {}, { method: 'POST', body: 'hello' })
- const buf = await ctx.arrayBuffer()
- assertEquals(buf.byteLength, 5)
-})
-
-Deno.test('Context#arrayBuffer returns cached on second call', async () => {
- const ctx = createTestContext('http://localhost/', {}, { method: 'POST', body: 'test' })
- const first = await ctx.arrayBuffer()
- const second = await ctx.arrayBuffer()
- assertEquals(first, second)
-})
-
-Deno.test('Context#arrayBuffer then json throws already consumed', async () => {
- const ctx = createTestContext('http://localhost/', {}, { method: 'POST', body: 'data' })
- await ctx.arrayBuffer()
- let thrown = false
- try {
- await ctx.json()
- } catch (e) {
- thrown = true
- assertEquals((e as Error).message, 'Request body already consumed')
- }
- assertEquals(thrown, true)
-})
-
-Deno.test('Context#blob reads body as Blob', async () => {
- const ctx = createTestContext('http://localhost/', {}, { method: 'POST', body: 'data' })
- const blob = await ctx.blob()
- assertEquals(blob.size, 4)
-})
-
-Deno.test('Context#blob returns cached on second call', async () => {
- const ctx = createTestContext('http://localhost/', {}, { method: 'POST', body: 'data' })
- const first = await ctx.blob()
- const second = await ctx.blob()
- assertEquals(first, second)
-})
-
-Deno.test('Context#blob then text throws already consumed', async () => {
- const ctx = createTestContext('http://localhost/', {}, { method: 'POST', body: 'data' })
- await ctx.blob()
- let thrown = false
- try {
- await ctx.text()
- } catch (e) {
- thrown = true
- assertEquals((e as Error).message, 'Request body already consumed')
- }
- assertEquals(thrown, true)
-})
-
-Deno.test('Context#body is not fooled by a parameter containing a type token', async () => {
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- {
- method: 'POST',
- body: '{"a":3}',
- headers: new Headers({ 'Content-Type': 'text/html; z=application/json' })
- }
- )
- const body = await ctx.body()
- assertEquals(body, '{"a":3}')
-})
-
-Deno.test('Context#body parses JSON and caches result', async () => {
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- {
- method: 'POST',
- body: JSON.stringify({ a: 1, b: 'x' }),
- headers: new Headers({ 'Content-Type': 'application/json' })
- }
- )
- const firstParsedBody = (await ctx.body()) as { a: number; b: string }
- assertEquals(firstParsedBody.a, 1)
- assertEquals(firstParsedBody.b, 'x')
- const secondParsedBody = await ctx.body()
- assertEquals(secondParsedBody, firstParsedBody)
-})
-
-Deno.test('Context#body parses form-urlencoded as FormData', async () => {
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- {
- method: 'POST',
- body: 'foo=bar&baz=qux',
- headers: new Headers({ 'Content-Type': 'application/x-www-form-urlencoded' })
- }
- )
- const formData = (await ctx.body()) as FormData
- assertEquals(formData.get('foo'), 'bar')
- assertEquals(formData.get('baz'), 'qux')
-})
-
-Deno.test('Context#body parses mixed-case Application/Json as JSON', async () => {
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- {
- method: 'POST',
- body: '{"a":2}',
- headers: new Headers({ 'Content-Type': 'Application/Json; charset=utf-8' })
- }
- )
- const body = (await ctx.body()) as { a: number }
- assertEquals(body.a, 2)
-})
-
-Deno.test('Context#body parses multipart/form-data', async () => {
- const multipartBody =
- `------TestBoundary\r\nContent-Disposition: form-data; name="field1"\r\n\r\nvalue1\r\n------TestBoundary--\r\n`
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- {
- method: 'POST',
- body: multipartBody,
- headers: new Headers({ 'Content-Type': `multipart/form-data; boundary=----TestBoundary` })
- }
- )
- const formData = (await ctx.body()) as FormData
- assertEquals(formData.get('field1'), 'value1')
-})
-
-Deno.test('Context#body parses uppercase application/json case-insensitively', async () => {
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- {
- method: 'POST',
- body: '{"a":1}',
- headers: new Headers({ 'Content-Type': 'APPLICATION/JSON' })
- }
- )
- const body = (await ctx.body()) as { a: number }
- assertEquals(body.a, 1)
-})
-
-Deno.test('Context#body re-throws a status-bearing JSON read error instead of returning null', async () => {
- const stream = new ReadableStream({
- pull() {
- throw Object.assign(new globalThis.Error('Payload Too Large'), { statusCode: 413 })
- }
+Deno.test('Context get.cookie parses cookie header', () => {
+ const ctx = Helper.createTestContext('http://localhost/', {
+ headers: { cookie: 'a=1; b=2' }
})
- const req = new Request(
- 'http://localhost/',
- {
- method: 'POST',
- body: stream,
- headers: new Headers({ 'Content-Type': 'application/json' }),
- duplex: 'half'
- } as RequestInit & { duplex: 'half' }
- )
- const ctx = new Core.Context(req, new URL('http://localhost/'), {})
- let thrown = false
- try {
- await ctx.body()
- } catch (e) {
- thrown = true
- assertEquals((e as { statusCode?: number }).statusCode, 413)
- }
- assertEquals(thrown, true)
+ assertEquals(ctx.get.cookie('a'), '1')
+ assertEquals(ctx.get.cookie('b'), '2')
})
-Deno.test('Context#body re-throws a status-bearing form read error instead of returning null', async () => {
- const stream = new ReadableStream({
- pull() {
- throw Object.assign(new globalThis.Error('Payload Too Large'), { statusCode: 413 })
- }
+Deno.test('Context get.header reads a single header value', () => {
+ const ctx = Helper.createTestContext('http://localhost/', {
+ headers: { 'x-test': 'value' }
})
- const req = new Request(
- 'http://localhost/',
- {
- method: 'POST',
- body: stream,
- headers: new Headers({ 'Content-Type': 'multipart/form-data; boundary=x' }),
- duplex: 'half'
- } as RequestInit & { duplex: 'half' }
- )
- const ctx = new Core.Context(req, new URL('http://localhost/'), {})
- let thrown = false
- try {
- await ctx.body()
- } catch (e) {
- thrown = true
- assertEquals((e as { statusCode?: number }).statusCode, 413)
- }
- assertEquals(thrown, true)
+ assertEquals(ctx.get.header('x-test'), 'value')
})
-Deno.test('Context#body returns null for malformed JSON instead of throwing', async () => {
- const ctx = createTestContext('http://localhost/', {}, {
+Deno.test('Context get.json reads JSON body', async () => {
+ const ctx = Helper.createTestContext('http://localhost/', {
method: 'POST',
- body: '{not valid json!!!',
- headers: new Headers({ 'Content-Type': 'application/json' })
+ body: JSON.stringify({ name: 'x' }),
+ headers: { 'content-type': 'application/json' }
})
- const result = await ctx.body()
- assertEquals(result, null)
+ assertEquals(await ctx.get.json(), { name: 'x' })
})
-Deno.test('Context#body returns null for malformed multipart instead of throwing', async () => {
- const ctx = createTestContext('http://localhost/', {}, {
+Deno.test('Context get.json throws after body was already consumed as text', async () => {
+ const ctx = Helper.createTestContext('http://localhost/', {
method: 'POST',
- body: 'this is not multipart data',
- headers: new Headers({ 'Content-Type': 'multipart/form-data; boundary=----nonexistent' })
+ body: 'hello'
})
- const result = await ctx.body()
- assertEquals(result, null)
-})
-
-Deno.test('Context#body then formData throws when body already parsed as non-form', async () => {
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- {
- method: 'POST',
- body: JSON.stringify({ a: 1 }),
- headers: new Headers({ 'Content-Type': 'application/json' })
- }
- )
- await ctx.body()
- let thrown = false
+ await ctx.get.text()
+ let threw = false
try {
- await ctx.formData()
- } catch (e) {
- thrown = true
- assertEquals((e as Error).message, 'Request body already consumed')
+ await ctx.get.json()
+ } catch {
+ threw = true
}
- assertEquals(thrown, true)
-})
-
-Deno.test('Context#body trims surrounding whitespace in content-type', async () => {
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- {
- method: 'POST',
- body: '{"a":4}',
- headers: new Headers({ 'Content-Type': ' application/json ' })
- }
- )
- const body = (await ctx.body()) as { a: number }
- assertEquals(body.a, 4)
-})
-
-Deno.test('Context#body with no content-type returns text', async () => {
- const ctx = createTestContext('http://localhost/', {}, { method: 'POST', body: 'default text' })
- const body = await ctx.body()
- assertEquals(body, 'default text')
-})
-
-Deno.test('Context#body with plain text content-type returns string', async () => {
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- {
- method: 'POST',
- body: 'just text',
- headers: new Headers({ 'Content-Type': 'text/plain' })
- }
- )
- const body = await ctx.body()
- assertEquals(body, 'just text')
-})
-
-Deno.test('Context#cookie caching returns same map on repeated calls', () => {
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- { headers: new Headers({ Cookie: 'a=1; b=2' }) }
- )
- const first = ctx.cookie() as Record
- const second = ctx.cookie() as Record
- assertEquals(first, second)
- assertEquals(first['a'], '1')
- assertEquals(first['b'], '2')
+ assertEquals(threw, true)
})
-Deno.test('Context#cookie does not let a non-breaking-space name shadow a real cookie', () => {
- const ctx = createTestContext('http://localhost/', {}, {
- headers: new Headers({ Cookie: '\u00A0sid=attacker; sid=legit' })
- })
- assertEquals(ctx.cookie('sid'), 'legit')
- assertEquals(ctx.cookie('\u00A0sid'), 'attacker')
+Deno.test('Context get.method returns request method', () => {
+ const ctx = Helper.createTestContext('http://localhost/', { method: 'POST' })
+ assertEquals(ctx.get.method(), 'POST')
})
-Deno.test('Context#cookie map uses a null prototype', () => {
- const ctx = createTestContext('http://localhost/', {}, {
- headers: new Headers({ Cookie: 'sid=abc' })
- })
- const cookies = ctx.cookie() as Record
- assertEquals(Object.getPrototypeOf(cookies), null)
+Deno.test('Context get.query reads a single query value', () => {
+ const ctx = Helper.createTestContext('http://localhost/?page=2')
+ assertEquals(ctx.get.query('page'), '2')
})
-Deno.test('Context#cookie returns first value when duplicate keys exist', () => {
- const ctx = createTestContext('http://localhost/', {}, {
- headers: new Headers({ Cookie: 'sid=first; sid=second' })
- })
- assertEquals(ctx.cookie('sid'), 'first')
+Deno.test('Context get.query returns a record when no key given', () => {
+ const ctx = Helper.createTestContext('http://localhost/?a=1&b=2')
+ assertEquals(ctx.get.query(), { a: '1', b: '2' })
})
-Deno.test('Context#cookie returns value by key', () => {
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- {
- headers: new Headers({ Cookie: 'sid=abc123; foo=bar' })
- }
- )
- assertEquals(ctx.cookie('sid'), 'abc123')
- assertEquals(ctx.cookie('foo'), 'bar')
- assertEquals(ctx.cookie('missing'), undefined)
+Deno.test('Context get.url and pathname expose parsed URL', () => {
+ const ctx = Helper.createTestContext('http://localhost/users?page=2')
+ assertEquals(ctx.get.url().href, 'http://localhost/users?page=2')
+ assertEquals(ctx.get.pathname(), '/users')
})
-Deno.test('Context#cookie skips entries without equals sign', () => {
- const ctx = createTestContext('http://localhost/', {}, {
- headers: new Headers({ Cookie: 'malformed; valid=yes; alsobroken' })
- })
- assertEquals(ctx.cookie('malformed'), undefined)
- assertEquals(ctx.cookie('valid'), 'yes')
- assertEquals(ctx.cookie('alsobroken'), undefined)
-})
-
-Deno.test('Context#cookie treats reserved names as plain data keys', () => {
- const ctx = createTestContext('http://localhost/', {}, {
- headers: new Headers({ Cookie: '__proto__=x; toString=y; constructor=z' })
- })
- const cookies = ctx.cookie() as Record
- assertEquals(cookies['__proto__'], 'x')
- assertEquals(cookies['toString'], 'y')
- assertEquals(cookies['constructor'], 'z')
- assertEquals(Object.hasOwn(cookies, '__proto__'), true)
-})
-
-Deno.test('Context#cookie trims only SP and HTAB around names like browsers do', () => {
- const ctx = createTestContext('http://localhost/', {}, {
- headers: new Headers({ Cookie: ' sid \t=value;\tfoo = bar' })
- })
- assertEquals(ctx.cookie('sid'), 'value')
- assertEquals(ctx.cookie('foo'), ' bar')
-})
-
-Deno.test('Context#cookie trims whitespace from cookie key', () => {
- const ctx = createTestContext('http://localhost/', {}, {
- headers: new Headers({ Cookie: ' token =abc123' })
- })
- assertEquals(ctx.cookie('token'), 'abc123')
-})
-
-Deno.test('Context#cookie with empty cookie header returns empty map', () => {
- const ctx = createTestContext('http://localhost/')
- const all = ctx.cookie() as Record
- assertEquals(Object.keys(all).length, 0)
-})
-
-Deno.test('Context#cookie with reserved names keeps Object prototype intact', () => {
- const ctx = createTestContext('http://localhost/', {}, {
- headers: new Headers({ Cookie: '__proto__=evil; toString=hijack' })
- })
- ctx.cookie()
- assertEquals(typeof ({}).toString, 'function')
- assertEquals(({} as Record)['evil' as string], undefined)
-})
-
-Deno.test('Context#cookie with value containing = sign', () => {
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- { headers: new Headers({ Cookie: 'token=abc=def=ghi' }) }
- )
- assertEquals(ctx.cookie('token'), 'abc=def=ghi')
-})
-
-Deno.test('Context#cookie without key returns all cookies', () => {
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- {
- headers: new Headers({ Cookie: 'a=1; b=2' })
- }
- )
- const allCookies = ctx.cookie() as Record
- assertEquals(allCookies['a'], '1')
- assertEquals(allCookies['b'], '2')
-})
-
-Deno.test('Context#formData reads form data directly', async () => {
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- {
- method: 'POST',
- body: 'key=value',
- headers: new Headers({ 'Content-Type': 'application/x-www-form-urlencoded' })
- }
- )
- const fd = await ctx.formData()
- assertEquals(fd.get('key'), 'value')
-})
-
-Deno.test('Context#formData returns cached on second call', async () => {
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- {
- method: 'POST',
- body: 'a=1',
- headers: new Headers({ 'Content-Type': 'application/x-www-form-urlencoded' })
- }
- )
- const first = await ctx.formData()
- const second = await ctx.formData()
- assertEquals(first, second)
-})
-
-Deno.test('Context#formData then blob throws already consumed', async () => {
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- {
- method: 'POST',
- body: 'key=value',
- headers: new Headers({ 'Content-Type': 'application/x-www-form-urlencoded' })
- }
- )
- await ctx.formData()
- let thrown = false
+Deno.test('Context get.validated throws without validate middleware', () => {
+ const ctx = Helper.createTestContext()
+ let threw = false
try {
- await ctx.blob()
+ ctx.get.validated()
} catch (e) {
- thrown = true
- assertEquals((e as Error).message, 'Request body already consumed')
+ threw = true
+ assertEquals(e instanceof Deno.errors.NotSupported, true)
}
- assertEquals(thrown, true)
-})
-
-Deno.test('Context#getState and setState are mutable and shared', () => {
- const ctx = createTestContext('http://localhost/')
- const customKey = Core.Handler.stateKey<{ user: string }>('customUser')
- assertEquals(ctx.getState(customKey), undefined)
- ctx.setState(customKey, { user: 'x' })
- assertEquals(ctx.getState(customKey)?.user, 'x')
+ assertEquals(threw, true)
})
-Deno.test('Context#handleError when errorHandler throws propagates', async () => {
- const request = new Request('http://localhost/')
- const ctx = new Core.Context(request, new URL('http://localhost/'), {}, (_ctx, _code, _err) => {
- throw new Error('handler threw')
- })
- let thrown = false
+Deno.test('Context get.worker throws without worker pool', () => {
+ const ctx = Helper.createTestContext()
+ let threw = false
try {
- await ctx.handleError(500, new Error('original'))
+ ctx.get.worker()
} catch (e) {
- thrown = true
- assertEquals((e as Error).message, 'handler threw')
+ threw = true
+ assertEquals(e instanceof Deno.errors.NotSupported, true)
}
- assertEquals(thrown, true)
+ assertEquals(threw, true)
})
-Deno.test('Context#handleError with handler uses custom response', async () => {
- const request = new Request('http://localhost/')
- const ctx = new Core.Context(request, new URL('http://localhost/'), {}, async (_, statusCode) => {
- return new Response(`custom ${statusCode}`, { status: statusCode })
+Deno.test('Context handleError builds a default error response', async () => {
+ const ctx = Helper.createTestContext('http://localhost/missing', {
+ headers: { accept: 'text/html' }
})
- const res = await ctx.handleError(418, new Error('teapot'))
- assertEquals(res.status, 418)
- assertEquals(await res.text(), 'custom 418')
-})
-
-Deno.test('Context#handleError without handler returns error page with status', async () => {
- const ctx = createTestContext('http://localhost/')
- const res = await ctx.handleError(503, new Error('unavailable'))
- assertEquals(res.status, 503)
- const body = await res.text()
- assertEquals(body.includes('503'), true)
+ const res = await ctx.handleError(404, new Error('nope'))
+ assertEquals(res.status, 404)
+ assertEquals(res.headers.get('content-type'), 'text/html; charset=utf-8')
})
-Deno.test('Context#handleError without handler returns response with status only', async () => {
- const ctx = createTestContext('http://localhost/')
- const res = await ctx.handleError(503, new Error('unavailable'))
- assertEquals(res.status, 503)
-})
-
-Deno.test('Context#header map uses a null prototype', () => {
- const ctx = createTestContext('http://localhost/', {}, {
- headers: new Headers({ 'X-Custom': 'value' })
- })
- const all = ctx.header() as Record
- assertEquals(Object.getPrototypeOf(all), null)
-})
-
-Deno.test('Context#header returns value by key (case-insensitive)', () => {
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- {
- headers: new Headers({ 'X-Custom': 'value', Accept: 'text/html' })
- }
- )
- assertEquals(ctx.header('x-custom'), 'value')
- assertEquals(ctx.header('Accept'), 'text/html')
-})
-
-Deno.test('Context#header treats reserved names as plain data keys', () => {
- const ctx = createTestContext('http://localhost/', {}, {
- headers: new Headers({ toString: 'y', constructor: 'z' })
+Deno.test('Context handleError negotiates JSON when accepted', async () => {
+ const ctx = Helper.createTestContext('http://localhost/missing', {
+ headers: { accept: 'application/json' }
})
- const all = ctx.header() as Record
- assertEquals(all['tostring'], 'y')
- assertEquals(all['constructor'], 'z')
- assertEquals(typeof ({}).toString, 'function')
+ const res = await ctx.handleError(404, new Error('nope'))
+ assertEquals(res.status, 404)
+ assertEquals(res.headers.get('content-type'), 'application/problem+json')
})
-Deno.test('Context#header without key returns all headers', () => {
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- { headers: new Headers({ 'X-A': '1', 'X-B': '2' }) }
- )
- const all = ctx.header() as Record
- assertEquals(all['x-a'], '1')
- assertEquals(all['x-b'], '2')
+Deno.test('Context internalOf exposes the control surface', () => {
+ const ctx = Helper.createTestContext()
+ const internal = Core.Context.internalOf(ctx)
+ assertEquals(typeof internal.setParams, 'function')
+ assertEquals(typeof internal.finalizeRaw, 'function')
})
-Deno.test('Context#json reads body as JSON', async () => {
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- {
- method: 'POST',
- body: JSON.stringify({ ok: true }),
- headers: new Headers({ 'Content-Type': 'application/json' })
- }
- )
- const data = await ctx.json()
- assertEquals(data, { ok: true })
-})
-
-Deno.test('Context#json returns cached on second call', async () => {
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- {
- method: 'POST',
- body: JSON.stringify({ x: 1 }),
- headers: new Headers({ 'Content-Type': 'application/json' })
- }
- )
- const first = await ctx.json()
- const second = await ctx.json()
- assertEquals(first, second)
-})
-
-Deno.test('Context#json then arrayBuffer throws already consumed', async () => {
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- {
- method: 'POST',
- body: JSON.stringify({ x: 1 }),
- headers: new Headers({ 'Content-Type': 'application/json' })
- }
- )
- await ctx.json()
- let thrown = false
+Deno.test('Context render throws when no view engine configured', async () => {
+ const ctx = Helper.createTestContext()
+ let threw = false
try {
- await ctx.arrayBuffer()
+ await ctx.render('template')
} catch (e) {
- thrown = true
- assertEquals((e as Error).message, 'Request body already consumed')
- }
- assertEquals(thrown, true)
-})
-
-Deno.test('Context#json then text throws already consumed', async () => {
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- {
- method: 'POST',
- body: JSON.stringify({ x: 1 }),
- headers: new Headers({ 'Content-Type': 'application/json' })
- }
- )
- await ctx.json()
- let thrown = false
- try {
- await ctx.text()
- } catch (e) {
- thrown = true
- assertEquals((e as Error).message, 'Request body already consumed')
- }
- assertEquals(thrown, true)
-})
-
-Deno.test('Context#param falls back to raw value on malformed percent-encoding', () => {
- const ctx = createTestContext('http://localhost/x', { id: '%', other: '%zz', plain: 'ok' })
- assertEquals(ctx.param('id'), '%')
- assertEquals(ctx.param('other'), '%zz')
- assertEquals(ctx.param('plain'), 'ok')
-})
-
-Deno.test('Context#param percent-decodes route params (consistent with query)', () => {
- const ctx = createTestContext('http://localhost/users/john%20doe', { id: 'john%20doe' })
- assertEquals(ctx.param('id'), 'john doe')
- const unicodeCtx = createTestContext('http://localhost/users/caf%C3%A9', { id: 'caf%C3%A9' })
- assertEquals(unicodeCtx.param('id'), 'café')
- const slashCtx = createTestContext('http://localhost/x', { id: 'a%2Fb' })
- assertEquals(slashCtx.param('id'), 'a/b')
-})
-
-Deno.test('Context#param returns route param by key', () => {
- const ctx = createTestContext('http://localhost/items/42', { id: '42' })
- assertEquals(ctx.param('id'), '42')
- assertEquals(ctx.param('missing'), undefined)
-})
-
-Deno.test('Context#params returns copy of route params', () => {
- const ctx = createTestContext('http://localhost/', { a: '1', b: '2' })
- const paramsCopy = ctx.params()
- assertEquals(paramsCopy, { a: '1', b: '2' })
- paramsCopy['a'] = 'x'
- assertEquals(ctx.param('a'), '1')
-})
-
-Deno.test('Context#pathname returns URL pathname', () => {
- const ctx = createTestContext('http://localhost/items/42')
- assertEquals(ctx.pathname, '/items/42')
-})
-
-Deno.test('Context#queries returns all values for a key', () => {
- const ctx = createTestContext('http://localhost/?tag=a&tag=b')
- assertEquals(ctx.queries('tag'), ['a', 'b'])
-})
-
-Deno.test('Context#queries returns empty array for missing key', () => {
- const ctx = createTestContext('http://localhost/?a=1')
- assertEquals(ctx.queries('missing'), [])
-})
-
-Deno.test('Context#query all params returns first-wins map', () => {
- const ctx = createTestContext('http://localhost/?a=1&a=2&b=3')
- const all = ctx.query() as Record
- assertEquals(all['a'], '1')
- assertEquals(all['b'], '3')
-})
-
-Deno.test('Context#query map uses a null prototype', () => {
- const ctx = createTestContext('http://localhost/?a=1')
- const all = ctx.query() as Record
- assertEquals(Object.getPrototypeOf(all), null)
-})
-
-Deno.test('Context#query returns first value when duplicate keys exist', () => {
- const ctx = createTestContext('http://localhost/?role=user&role=admin')
- assertEquals(ctx.query('role'), 'user')
-})
-
-Deno.test('Context#query returns query value by key', () => {
- const ctx = createTestContext('http://localhost/?foo=bar&baz=qux')
- assertEquals(ctx.query('foo'), 'bar')
- assertEquals(ctx.query('baz'), 'qux')
- assertEquals(ctx.query('missing'), undefined)
-})
-
-Deno.test('Context#query treats reserved names as plain data keys', () => {
- const ctx = createTestContext('http://localhost/?__proto__=p&toString=t&constructor=c')
- const all = ctx.query() as Record
- assertEquals(all['__proto__'], 'p')
- assertEquals(all['toString'], 't')
- assertEquals(all['constructor'], 'c')
- assertEquals(Object.hasOwn(all, '__proto__'), true)
-})
-
-Deno.test('Context#query without key returns all params', () => {
- const ctx = createTestContext('http://localhost/?a=1&b=2')
- const all = ctx.query() as Record
- assertEquals(all['a'], '1')
- assertEquals(all['b'], '2')
-})
-
-Deno.test('Context#redirect blocks open-redirect normalization bypass', () => {
- const ctx = createTestContext('http://localhost/login')
- let thrown = false
- try {
- ctx.redirect('/\\evil.com')
- } catch (e) {
- thrown = true
- assertEquals(e instanceof Deno.errors.InvalidData, true)
- }
- assertEquals(thrown, true)
-})
-
-Deno.test('Context#redirect defaults to 302', () => {
- const ctx = createTestContext('http://localhost/login')
- const res = ctx.redirect('/home')
- assertEquals(res.status, 302)
-})
-
-Deno.test('Context#redirect returns same-origin relative redirect (documented convenience)', () => {
- const ctx = createTestContext('http://localhost/login')
- const res = ctx.redirect('/dashboard', 301)
- assertEquals(res.status, 301)
- assertEquals(res.headers.get('Location'), 'http://localhost/dashboard')
-})
-
-Deno.test('Context#redirect with extra headers merges them', () => {
- const ctx = createTestContext('http://localhost/')
- const res = ctx.redirect('/login', 302, { headers: new Headers({ 'X-Extra': 'val' }) })
- assertEquals(res.headers.get('Location'), 'http://localhost/login')
- assertEquals(res.headers.get('X-Extra'), 'val')
-})
-
-Deno.test('Context#render throws Deno.errors.NotSupported', async () => {
- const ctx = createTestContext('http://localhost/')
- let thrown = false
- try {
- await ctx.render('hello.dve')
- } catch (e) {
- thrown = true
+ threw = true
assertEquals(e instanceof Deno.errors.NotSupported, true)
}
- assertEquals(thrown, true)
-})
-
-Deno.test('Context#render throws when view engine not configured', async () => {
- const ctx = createTestContext('http://localhost/')
- let thrown = false
- try {
- await ctx.render('hello.dve')
- } catch (e) {
- thrown = true
- assertEquals((e as Error).message.includes('View engine not configured'), true)
- }
- assertEquals(thrown, true)
+ assertEquals(threw, true)
})
-Deno.test('Context#replaceRequest resets body so body can be read again', async () => {
- const ctx = createTestContext(
- 'http://localhost/',
- {},
- {
- method: 'POST',
- body: 'foo=bar',
- headers: new Headers({ 'Content-Type': 'application/x-www-form-urlencoded' })
- }
- )
- const first = (await ctx.body()) as FormData
- assertEquals(first.get('foo'), 'bar')
- const newReq = new Request('http://localhost/', {
- method: 'POST',
- body: 'baz=qux',
- headers: new Headers({ 'Content-Type': 'application/x-www-form-urlencoded' })
- })
- ctx[Core.InternalContext].replaceRequest(newReq)
- const second = (await ctx.body()) as FormData
- assertEquals(second.get('baz'), 'qux')
+Deno.test('Context send.empty builds a null body response', () => {
+ const ctx = Helper.createTestContext()
+ const res = ctx.send.empty(204)
+ assertEquals(res.status, 204)
+ assertEquals(res.body, null)
})
-Deno.test('Context#responseHeadersMap mutation does not corrupt emitted response headers', () => {
- const ctx = createTestContext()
- ctx.setHeader('X-Real', 'real')
- const map = ctx[Core.InternalContext].responseHeadersMap as Record
- map['X-Injected-Via-Map'] = 'evil'
- delete map['X-Real']
- const res = ctx.send.html('ok
')
- assertEquals(res.headers.get('X-Real'), 'real')
- assertEquals(res.headers.get('X-Injected-Via-Map'), null)
-})
-
-Deno.test('Context#responseHeadersMap returns snapshot, mutation does not leak into response', () => {
- const ctx = createTestContext()
- ctx.setHeader('X-Real', 'real')
- const map = ctx[Core.InternalContext].responseHeadersMap
- ;(map as Record)['X-Injected'] = 'mutated'
- delete (map as Record)['X-Real']
- const fresh = ctx[Core.InternalContext].responseHeadersMap
- assertEquals(fresh['X-Real'], 'real')
- assertEquals(fresh['X-Injected'], undefined)
-})
-
-Deno.test('Context#send.data with Uint8Array', () => {
- const ctx = createTestContext('http://localhost/')
- const data = new TextEncoder().encode('binary data')
- const res = ctx.send.data(data, 'file.bin')
- assertEquals(res.headers.get('Content-Disposition'), 'attachment; filename="file.bin"')
-})
-
-Deno.test('Context#send.html returns 200 HTML response', () => {
- const ctx = createTestContext('http://localhost/')
- const res = ctx.send.html('ok
')
- assertEquals(res.status, 200)
- assertEquals(res.headers.get('Content-Type'), 'text/html; charset=utf-8')
-})
-
-Deno.test('Context#send.json returns 200 with application/json', () => {
- const ctx = createTestContext('http://localhost/')
+Deno.test('Context send.json builds a JSON response', async () => {
+ const ctx = Helper.createTestContext()
const res = ctx.send.json({ ok: true })
- assertEquals(res.status, 200)
- assertEquals(res.headers.get('Content-Type'), 'application/json')
-})
-
-Deno.test('Context#send.redirect returns 302 with Location header', () => {
- const ctx = createTestContext('http://localhost/')
- const res = ctx.send.redirect('/login')
- assertEquals(res.status, 302)
- assertEquals(res.headers.get('Location'), 'http://localhost/login')
-})
-
-Deno.test('Context#send.redirect with extra headers', () => {
- const ctx = createTestContext('http://localhost/')
- const res = ctx.send.redirect('/login', 302, { headers: new Headers({ 'X-Extra': 'val' }) })
- assertEquals(res.status, 302)
- assertEquals(res.headers.get('Location'), 'http://localhost/login')
- assertEquals(res.headers.get('X-Extra'), 'val')
-})
-
-Deno.test('Context#send.redirect with status uses given status', () => {
- const ctx = createTestContext('http://localhost/')
- const res = ctx.send.redirect('/gone', 301)
- assertEquals(res.status, 301)
-})
-
-Deno.test('Context#send.text returns text/plain', () => {
- const ctx = createTestContext('http://localhost/')
- const res = ctx.send.text('plain')
- assertEquals(res.status, 200)
- assertEquals(res.headers.get('Content-Type'), 'text/plain; charset=utf-8')
+ assertEquals(await res.json(), { ok: true })
+ assertEquals(res.headers.get('content-type'), 'application/json')
})
-Deno.test('Context#setHeader Set-Cookie does not appear in responseHeadersMap', () => {
- const ctx = createTestContext()
- ctx.setHeader('Set-Cookie', 'token=abc')
- ctx.setHeader('X-Custom', 'yes')
- assertEquals(ctx[Core.InternalContext].responseHeadersMap['X-Custom'], 'yes')
- assertEquals(ctx[Core.InternalContext].responseHeadersMap['Set-Cookie'], undefined)
-})
-
-Deno.test('Context#setHeader accumulates multiple Set-Cookie values', () => {
- const ctx = createTestContext()
- ctx.setHeader('Set-Cookie', 'a=1; Path=/')
- ctx.setHeader('Set-Cookie', 'b=2; Path=/')
- ctx.setHeader('Set-Cookie', 'c=3; Path=/')
- assertEquals(ctx[Core.InternalContext].responseCookies.length, 3)
- assertEquals(ctx[Core.InternalContext].responseCookies[0], 'a=1; Path=/')
- assertEquals(ctx[Core.InternalContext].responseCookies[2], 'c=3; Path=/')
-})
-
-Deno.test('Context#setHeader chaining works', () => {
- const ctx = createTestContext('http://localhost/')
- const result = ctx.setHeader('X-A', '1').setHeader('X-B', '2')
- assertEquals(result, ctx)
- assertEquals(ctx[Core.InternalContext].responseHeadersMap['X-A'], '1')
- assertEquals(ctx[Core.InternalContext].responseHeadersMap['X-B'], '2')
-})
-
-Deno.test('Context#setHeader merges into response', () => {
- const ctx = createTestContext('http://localhost/')
- ctx.setHeader('X-Custom', 'value')
- const res = ctx.send.html('ok
')
- assertEquals(res.headers.get('X-Custom'), 'value')
- assertEquals(res.headers.get('Content-Type'), 'text/html; charset=utf-8')
-})
-
-Deno.test('Context#setHeader rejects an invalid header name', () => {
- const ctx = createTestContext('http://localhost/')
- let thrown = false
- try {
- ctx.setHeader('Bad\nName', 'value')
- } catch {
- thrown = true
- }
- assertEquals(thrown, true)
-})
-
-Deno.test('Context#setHeader rejects an invalid header value', () => {
- const ctx = createTestContext('http://localhost/')
- let thrown = false
- try {
- ctx.setHeader('X-Ok', 'bad\nvalue')
- } catch {
- thrown = true
- }
- assertEquals(thrown, true)
-})
-
-Deno.test('Context#setHeaders applies nothing when a later entry is invalid', () => {
- const ctx = createTestContext('http://localhost/')
- try {
- ctx.setHeaders({ 'X-Good': 'ok', 'In valid': 'bad' })
- } catch {
- ctx
- }
- assertEquals(ctx[Core.InternalContext].responseHeadersMap['X-Good'], undefined)
-})
-
-Deno.test('Context#setHeaders rejects the whole batch when one entry is invalid', () => {
- const ctx = createTestContext('http://localhost/')
- let thrown = false
+Deno.test('Context send.json rejects invalid status code', () => {
+ const ctx = Helper.createTestContext()
+ let threw = false
try {
- ctx.setHeaders({ 'X-Good': 'ok', 'In valid': 'bad' })
- } catch {
- thrown = true
- }
- assertEquals(thrown, true)
-})
-
-Deno.test('Context#setHeaders returns this for chaining', () => {
- const ctx = createTestContext('http://localhost/')
- const result = ctx.setHeaders({ 'X-A': '1', 'X-B': '2' })
- assertEquals(result, ctx)
-})
-
-Deno.test('Context#setHeaders routes Set-Cookie entries to cookie array', () => {
- const ctx = createTestContext()
- ctx.setHeaders({ 'X-A': '1', 'Set-Cookie': 'sid=abc', 'X-B': '2' })
- assertEquals(ctx[Core.InternalContext].responseCookies.length, 1)
- assertEquals(ctx[Core.InternalContext].responseCookies[0], 'sid=abc')
- assertEquals(ctx[Core.InternalContext].responseHeadersMap['X-A'], '1')
- assertEquals(ctx[Core.InternalContext].responseHeadersMap['X-B'], '2')
-})
-
-Deno.test('Context#setHeaders sets multiple headers', () => {
- const ctx = createTestContext('http://localhost/')
- ctx.setHeaders({ 'X-A': '1', 'X-B': '2' })
- const res = ctx.send.html('ok
')
- assertEquals(res.headers.get('X-A'), '1')
- assertEquals(res.headers.get('X-B'), '2')
-})
-
-Deno.test('Context#setParams merges additional params', () => {
- const ctx = createTestContext('http://localhost/', { id: '1' })
- ctx[Core.InternalContext].setParams({ name: 'test' })
- assertEquals(ctx.param('id'), '1')
- assertEquals(ctx.param('name'), 'test')
-})
-
-Deno.test('Context#setParams percent-decodes merged params', () => {
- const ctx = createTestContext('http://localhost/', {})
- ctx[Core.InternalContext].setParams({ name: 'a%20b' })
- assertEquals(ctx.param('name'), 'a b')
-})
-
-Deno.test('Context#setState rejects reserved framework keys', () => {
- const ctx = createTestContext()
- for (const reserved of ['view', 'worker', 'session', 'setSession', 'clearSession']) {
- let threw = false
- try {
- ctx.setState(Core.Handler.stateKey(reserved), 'attacker')
- } catch (e) {
- threw = true
- assertEquals((e as Error).message.includes('reserved'), true)
- }
- assertEquals(threw, true)
- }
-})
-
-Deno.test('Context#state cannot clobber framework internals via delete', () => {
- const ctx = createTestContext()
- ctx[Core.InternalContext].setInternalState(
- Core.Handler.stateKeys.view,
- { render: async () => '' } as never
- )
- delete (ctx.state as Record)['view']
- assertEquals(ctx.getState(Core.Handler.stateKeys.view) !== undefined, true)
-})
-
-Deno.test('Context#state does not expose framework-wired internal keys', () => {
- const ctx = createTestContext()
- ctx[Core.InternalContext].setInternalState(Core.Handler.stateKeys.setSession, async () => {})
- ctx[Core.InternalContext].setInternalState(Core.Handler.stateKeys.session, { userId: '1' })
- assertEquals(typeof ctx.getState(Core.Handler.stateKeys.setSession), 'function')
- assertEquals(ctx.getState(Core.Handler.stateKeys.session)?.['userId'], '1')
- assertEquals(Object.hasOwn(ctx.state, 'setSession'), false)
- assertEquals(Object.hasOwn(ctx.state, 'session'), false)
-})
-
-Deno.test('Context#state exposes a live mutable record (documented API)', () => {
- const ctx = createTestContext()
- assertEquals(typeof ctx.state, 'object')
- ctx.state['foo'] = 'bar'
- assertEquals(ctx.state['foo'], 'bar')
-})
-
-Deno.test('Context#streamRender throws Deno.errors.NotSupported', async () => {
- const ctx = createTestContext('http://localhost/')
- let thrown = false
- try {
- await ctx.streamRender('hello.dve')
+ ctx.send.json({}, { status: 999 as 200 })
} catch (e) {
- thrown = true
- assertEquals(e instanceof Deno.errors.NotSupported, true)
- }
- assertEquals(thrown, true)
-})
-
-Deno.test('Context#streamRender throws when view engine not configured', async () => {
- const ctx = createTestContext('http://localhost/')
- let thrown = false
- try {
- await ctx.streamRender('hello.dve')
- } catch (e) {
- thrown = true
- assertEquals((e as Error).message.includes('View engine not configured'), true)
+ threw = true
+ assertEquals(e instanceof Deno.errors.InvalidData, true)
}
- assertEquals(thrown, true)
+ assertEquals(threw, true)
})
-Deno.test('Context#text reads body as string', async () => {
- const ctx = createTestContext('http://localhost/', {}, { method: 'POST', body: 'plain text' })
- const text = await ctx.text()
- assertEquals(text, 'plain text')
+Deno.test('Context send.text builds a text response', async () => {
+ const ctx = Helper.createTestContext()
+ const res = ctx.send.text('hello')
+ assertEquals(await res.text(), 'hello')
+ assertEquals(res.headers.get('content-type'), 'text/plain; charset=utf-8')
})
-Deno.test('Context#text returns cached on second call', async () => {
- const ctx = createTestContext('http://localhost/', {}, { method: 'POST', body: 'cached' })
- const first = await ctx.text()
- const second = await ctx.text()
- assertEquals(first, second)
+Deno.test('Context set.header and set.cookie reflect in response', () => {
+ const ctx = Helper.createTestContext()
+ ctx.set.header('x-a', '1').cookie('sid', 'abc')
+ const res = ctx.send.text('x')
+ assertEquals(res.headers.get('x-a'), '1')
+ assertEquals(res.headers.get('set-cookie')?.startsWith('sid=abc'), true)
})
-Deno.test('Context#text then arrayBuffer throws already consumed', async () => {
- const ctx = createTestContext('http://localhost/', {}, { method: 'POST', body: 'data' })
- await ctx.text()
- let thrown = false
+Deno.test('Context set.session write throws without session middleware', async () => {
+ const ctx = Helper.createTestContext()
+ let threw = false
try {
- await ctx.arrayBuffer()
+ await ctx.set.session({ id: 1 })
} catch (e) {
- thrown = true
- assertEquals((e as Error).message, 'Request body already consumed')
+ threw = true
+ assertEquals(e instanceof Deno.errors.NotSupported, true)
}
- assertEquals(thrown, true)
+ assertEquals(threw, true)
})
-Deno.test('Context#url returns request url', () => {
- const ctx = createTestContext('http://localhost/items?q=1')
- assertEquals(ctx.url, 'http://localhost/items?q=1')
+Deno.test('Context setParams decodes percent-encoded values', () => {
+ const ctx = Helper.createTestContext('http://localhost/')
+ Core.Context.internalOf(ctx).setParams({ name: 'a%20b' })
+ assertEquals(ctx.get.param('name'), 'a b')
})
diff --git a/tests/core/Response.test.ts b/tests/core/Response.test.ts
deleted file mode 100644
index dfbd065..0000000
--- a/tests/core/Response.test.ts
+++ /dev/null
@@ -1,398 +0,0 @@
-import { assertEquals, assertThrows } from '@std/assert'
-import { fileURLToPath } from 'node:url'
-import * as Core from '@core/index.ts'
-
-const baseHeaders = { 'X-App': 'test' }
-const buildRedirect = (url: string, status: number): globalThis.Response =>
- new globalThis.Response(null, { status, headers: new Headers({ Location: url }) })
-const send = Core.Response.create(baseHeaders, [], buildRedirect)
-
-Deno.test('Response#create accepts valid 2xx-5xx and null-body statuses across send helpers', () => {
- assertEquals(send.json({ ok: true }, { status: 200 }).status, 200)
- assertEquals(send.text('x', { status: 599 }).status, 599)
- assertEquals(send.html('x', { status: 404 }).status, 404)
- assertEquals(send.custom('x', { status: 201 }).status, 201)
- assertEquals(send.json({ ok: true }, { status: 204 }).status, 204)
-})
-
-Deno.test('Response#create custom drops the body for a no-content status', async () => {
- const res = send.custom('ignored', { status: 204 })
- assertEquals(res.status, 204)
- assertEquals(res.body, null)
- assertEquals(await res.text(), '')
-})
-
-Deno.test('Response#create custom includes Set-Cookie values', () => {
- const cookieSend = Core.Response.create({}, ['a=1; Path=/', 'b=2; Path=/'], buildRedirect)
- const res = cookieSend.custom('body', { status: 200 })
- const setCookies = res.headers.getSetCookie()
- assertEquals(setCookies.length, 2)
-})
-
-Deno.test('Response#create custom merges base headers and options', async () => {
- const res = send.custom('body', { status: 201, headers: { 'X-Custom': 'y' } })
- assertEquals(res.status, 201)
- assertEquals(res.headers.get('X-App'), 'test')
- assertEquals(res.headers.get('X-Custom'), 'y')
- assertEquals(await res.text(), 'body')
-})
-
-Deno.test('Response#create custom options.headers overrides base', () => {
- const res = send.custom(null, { headers: { 'X-App': 'override' } })
- assertEquals(res.headers.get('X-App'), 'override')
-})
-
-Deno.test('Response#create custom with Headers instance merges correctly', () => {
- const res = send.custom(null, { headers: new Headers({ 'X-Test': 'yes' }) })
- assertEquals(res.headers.get('X-App'), 'test')
- assertEquals(res.headers.get('X-Test'), 'yes')
-})
-
-Deno.test('Response#create custom with array headers merges correctly', () => {
- const headers: [string, string][] = [['X-Arr', 'val']]
- const res = send.custom(null, { headers })
- assertEquals(res.headers.get('X-App'), 'test')
- assertEquals(res.headers.get('X-Arr'), 'val')
-})
-
-Deno.test('Response#create custom with no body returns empty response', async () => {
- const res = send.custom(null, { status: 204 })
- assertEquals(res.status, 204)
- assertEquals(await res.text(), '')
-})
-
-Deno.test('Response#create custom with null body and no options', async () => {
- const res = send.custom(null)
- assertEquals(res.status, 200)
- assertEquals(await res.text(), '')
-})
-
-Deno.test('Response#create data drops the body and length for a no-content status', async () => {
- const res = send.data('bytes', 'file.txt', { status: 204 }, 'text/plain')
- assertEquals(res.status, 204)
- assertEquals(res.body, null)
- assertEquals(res.headers.get('Content-Length'), null)
- assertEquals(await res.text(), '')
-})
-
-Deno.test('Response#create data encodes non-ASCII filename via RFC 6266 filename*', () => {
- const res = send.data(new TextEncoder().encode('x'), 'résumé-日本.pdf')
- const cd = res.headers.get('Content-Disposition') ?? ''
- assertEquals(res.status, 200)
- assertEquals(cd.includes("filename*=UTF-8''"), true)
- assertEquals(cd.includes(encodeURIComponent('résumé-日本.pdf')), true)
- assertEquals(cd.includes('filename="r_sum_-__.pdf"'), true)
-})
-
-Deno.test('Response#create data escapes a backslash path separator from filename', () => {
- const res = send.data(new TextEncoder().encode('x'), 'a\\b.txt')
- const cd = res.headers.get('Content-Disposition') ?? ''
- assertEquals(cd, 'attachment; filename="b.txt"')
-})
-
-Deno.test('Response#create data escapes quotes in filename', () => {
- const res = send.data(new TextEncoder().encode('x'), 'file"name.txt')
- const cd = res.headers.get('Content-Disposition') ?? ''
- assertEquals(cd.includes('\\"'), true)
- assertEquals(cd.includes('filename'), true)
-})
-
-Deno.test('Response#create data omits filename* for pure-ASCII filename', () => {
- const res = send.data(new TextEncoder().encode('x'), 'plain.txt')
- const cd = res.headers.get('Content-Disposition') ?? ''
- assertEquals(cd, 'attachment; filename="plain.txt"')
- assertEquals(cd.includes('filename*'), false)
-})
-
-Deno.test('Response#create data sets Content-Disposition and Content-Type', () => {
- const res = send.data(new TextEncoder().encode('data'), 'file.bin', undefined, 'application/pdf')
- assertEquals(res.headers.get('Content-Type'), 'application/pdf')
- assertEquals(res.headers.get('Content-Disposition'), 'attachment; filename="file.bin"')
- assertEquals(res.headers.get('Content-Length'), '4')
- assertEquals(res.headers.get('X-App'), 'test')
-})
-
-Deno.test('Response#create data string sets Content-Length', () => {
- const res = send.data('hello', 'a.txt')
- assertEquals(res.headers.get('Content-Length'), '5')
- assertEquals(res.headers.get('Content-Disposition'), 'attachment; filename="a.txt"')
-})
-
-Deno.test('Response#create data strips DEL character from filename', () => {
- const res = send.data(new TextEncoder().encode('x'), 'file\x7Fname.txt')
- const cd = res.headers.get('Content-Disposition') ?? ''
- assertEquals(cd.includes('\x7F'), false)
-})
-
-Deno.test('Response#create data strips control characters from filename', () => {
- const res = send.data(new TextEncoder().encode('x'), 'file\x00name\x1F.txt')
- const cd = res.headers.get('Content-Disposition') ?? ''
- assertEquals(cd.includes('\x00'), false)
- assertEquals(cd.includes('\x1F'), false)
- assertEquals(cd.includes('filename.txt'), true)
-})
-
-Deno.test('Response#create data strips path separators from filename', () => {
- const res = send.data(new TextEncoder().encode('x'), '../../etc/passwd')
- const cd = res.headers.get('Content-Disposition') ?? ''
- assertEquals(cd.includes('..'), false)
- assertEquals(cd.includes('passwd'), true)
-})
-
-Deno.test('Response#create data with Uint8Array sets Content-Length', () => {
- const data = new TextEncoder().encode('hello')
- const res = send.data(data, 'file.bin')
- assertEquals(res.headers.get('Content-Length'), '5')
-})
-
-Deno.test('Response#create data with a non-ASCII name escapes quotes in the ASCII fallback', () => {
- const res = send.data(new TextEncoder().encode('x'), '发"票.pdf')
- const cd = res.headers.get('Content-Disposition') ?? ''
- assertEquals(cd.includes('filename="_\\"_.pdf"'), true)
- assertEquals(cd.includes(`filename*=UTF-8''${encodeURIComponent('发"票.pdf')}`), true)
-})
-
-Deno.test('Response#create data with default content-type uses octet-stream', () => {
- const res = send.data('hello', 'a.txt')
- assertEquals(res.headers.get('Content-Type'), 'application/octet-stream')
-})
-
-Deno.test('Response#create data with empty Uint8Array', () => {
- const res = send.data(new Uint8Array(0), 'empty.bin')
- assertEquals(res.headers.get('Content-Length'), '0')
- assertEquals(res.headers.get('Content-Disposition'), 'attachment; filename="empty.bin"')
-})
-
-Deno.test('Response#create file reads file and sets headers', async () => {
- const filePath = fileURLToPath(import.meta.resolve('@tests/fixtures/response-file.txt'))
- const res = await send.file(filePath, 'custom.txt')
- assertEquals(res.headers.get('Content-Disposition'), 'attachment; filename="custom.txt"')
- assertEquals(res.headers.get('Content-Type'), 'application/octet-stream')
- assertEquals(await res.text(), 'fixture content\n')
-})
-
-Deno.test('Response#create file with a non-ASCII filename emits an RFC 6266 filename* parameter', async () => {
- const filePath = fileURLToPath(import.meta.resolve('@tests/fixtures/response-file.txt'))
- const res = await send.file(filePath, '发票-résumé.pdf')
- const cd = res.headers.get('Content-Disposition') ?? ''
- assertEquals(cd.includes('filename="__-r_sum_.pdf"'), true)
- assertEquals(cd.includes(`filename*=UTF-8''${encodeURIComponent('发票-résumé.pdf')}`), true)
- await res.body?.cancel()
-})
-
-Deno.test('Response#create file with missing path throws', async () => {
- let thrown = false
- try {
- await send.file('/nonexistent/path/file.txt')
- } catch (e) {
- thrown = true
- assertEquals((e as globalThis.Error).message.includes('Failed to read file'), true)
- }
- assertEquals(thrown, true)
-})
-
-Deno.test('Response#create file with no custom filename uses path basename', async () => {
- const filePath = fileURLToPath(import.meta.resolve('@tests/fixtures/response-file.txt'))
- const res = await send.file(filePath)
- assertEquals(res.headers.get('Content-Disposition'), 'attachment; filename="response-file.txt"')
- assertEquals(await res.text(), 'fixture content\n')
-})
-
-Deno.test('Response#create helper Content-Type wins over a generic context Content-Type', async () => {
- const ctxHeaders = { 'Content-Type': 'application/xml', 'X-Req': '1' }
- const sendCtx = Core.Response.create(ctxHeaders, [], buildRedirect)
- const jsonRes = sendCtx.json({ ok: true })
- assertEquals(jsonRes.headers.get('Content-Type'), 'application/json')
- assertEquals(jsonRes.headers.get('X-Req'), '1')
- assertEquals(await jsonRes.json(), { ok: true })
- assertEquals(sendCtx.text('hi').headers.get('Content-Type'), 'text/plain; charset=utf-8')
- assertEquals(sendCtx.html('x').headers.get('Content-Type'), 'text/html; charset=utf-8')
-})
-
-Deno.test('Response#create html drops the body for a reset-content status', async () => {
- const res = send.html('ignored', { status: 205 })
- assertEquals(res.status, 205)
- assertEquals(res.body, null)
- assertEquals(await res.text(), '')
-})
-
-Deno.test('Response#create html includes Set-Cookie values', () => {
- const cookieSend = Core.Response.create({}, ['sid=xyz'], buildRedirect)
- const res = cookieSend.html('ok
')
- assertEquals(res.headers.getSetCookie().length, 1)
-})
-
-Deno.test('Response#create html sets text/html', async () => {
- const res = send.html('hi
', { status: 200 })
- assertEquals(res.headers.get('Content-Type'), 'text/html; charset=utf-8')
- assertEquals(await res.text(), 'hi
')
-})
-
-Deno.test('Response#create html with empty string', async () => {
- const res = send.html('')
- assertEquals(res.status, 200)
- assertEquals(res.headers.get('Content-Type'), 'text/html; charset=utf-8')
- assertEquals(await res.text(), '')
-})
-
-Deno.test('Response#create html with large content', async () => {
- const largeHtml = '' + 'x'.repeat(10000) + '
'
- const res = send.html(largeHtml)
- assertEquals(res.headers.get('Content-Type'), 'text/html; charset=utf-8')
- assertEquals(await res.text(), largeHtml)
-})
-
-Deno.test('Response#create json drops the body for a no-content status', async () => {
- const res = send.json({ ignored: true }, { status: 204 })
- assertEquals(res.status, 204)
- assertEquals(res.body, null)
- assertEquals(await res.text(), '')
-})
-
-Deno.test('Response#create json includes Set-Cookie values', () => {
- const cookieSend = Core.Response.create({}, ['token=abc'], buildRedirect)
- const res = cookieSend.json({ ok: true })
- assertEquals(res.headers.getSetCookie().length, 1)
-})
-
-Deno.test('Response#create json serializes and sets application/json', async () => {
- const res = send.json({ a: 1 }, { status: 200 })
- assertEquals(res.headers.get('Content-Type'), 'application/json')
- assertEquals(await res.json(), { a: 1 })
-})
-
-Deno.test('Response#create json with array value', async () => {
- const res = send.json([1, 2, 3])
- assertEquals(res.status, 200)
- assertEquals(res.headers.get('Content-Type'), 'application/json')
- assertEquals(await res.json(), [1, 2, 3])
-})
-
-Deno.test('Response#create json with nested object', async () => {
- const res = send.json({ nested: { array: [1, 2, 3] } })
- assertEquals(res.headers.get('Content-Type'), 'application/json')
- const body = await res.json()
- assertEquals(body, { nested: { array: [1, 2, 3] } })
-})
-
-Deno.test('Response#create json with null value', async () => {
- const res = send.json(null)
- assertEquals(res.status, 200)
- assertEquals(res.headers.get('Content-Type'), 'application/json')
- assertEquals(await res.text(), 'null')
-})
-
-Deno.test('Response#create json with number value', async () => {
- const res = send.json(42)
- assertEquals(res.status, 200)
- assertEquals(res.headers.get('Content-Type'), 'application/json')
- assertEquals(await res.json(), 42)
-})
-
-Deno.test('Response#create keeps the body for an ordinary status', async () => {
- const res = send.json({ ok: true }, { status: 200 })
- assertEquals(res.status, 200)
- assertEquals(await res.json(), { ok: true })
-})
-
-Deno.test('Response#create per-call Content-Type override still wins over the helper default', () => {
- const res = send.json({ a: 1 }, { headers: { 'Content-Type': 'text/plain' } })
- assertEquals(res.headers.get('Content-Type'), 'text/plain')
-})
-
-Deno.test('Response#create redirect defaults to 302', () => {
- const res = send.redirect('https://example.com/')
- assertEquals(res.status, 302)
- assertEquals(res.headers.get('Location'), 'https://example.com/')
-})
-
-Deno.test('Response#create redirect delegates to buildRedirect', () => {
- const res = send.redirect('https://example.com/', 301)
- assertEquals(res.status, 301)
- assertEquals(res.headers.get('Location'), 'https://example.com/')
-})
-
-Deno.test('Response#create redirect with 308', () => {
- const res = send.redirect('https://example.com/', 308)
- assertEquals(res.status, 308)
- assertEquals(res.headers.get('Location'), 'https://example.com/')
-})
-
-Deno.test('Response#create rejects an out-of-range or non-integer status before the constructor throws', () => {
- for (
- const status of [99, 100, 199, 600, 1000, 0, -1, Number.NaN, 3.5, Number.POSITIVE_INFINITY]
- ) {
- assertThrows(
- () => send.json({ ok: true }, { status }),
- Deno.errors.InvalidData,
- '200-599'
- )
- }
-})
-
-Deno.test('Response#create stream drops the body for a no-content status', () => {
- const res = send.stream(new ReadableStream(), { status: 204 })
- assertEquals(res.status, 204)
- assertEquals(res.body, null)
-})
-
-Deno.test('Response#create stream sets Content-Type', () => {
- const stream = new ReadableStream({
- start(controller) {
- controller.enqueue(new TextEncoder().encode('x'))
- controller.close()
- }
- })
- const res = send.stream(stream, undefined, 'text/plain')
- assertEquals(res.headers.get('Content-Type'), 'text/plain')
- assertEquals(res.headers.get('X-App'), 'test')
-})
-
-Deno.test('Response#create stream with custom options', () => {
- const stream = new ReadableStream({
- start(controller) {
- controller.enqueue(new TextEncoder().encode('data'))
- controller.close()
- }
- })
- const res = send.stream(stream, { status: 201 }, 'text/event-stream')
- assertEquals(res.status, 201)
- assertEquals(res.headers.get('Content-Type'), 'text/event-stream')
- assertEquals(res.headers.get('X-App'), 'test')
-})
-
-Deno.test('Response#create stream with default contentType uses octet-stream', () => {
- const stream = new ReadableStream({
- start(controller) {
- controller.enqueue(new TextEncoder().encode('data'))
- controller.close()
- }
- })
- const res = send.stream(stream)
- assertEquals(res.headers.get('Content-Type'), 'application/octet-stream')
-})
-
-Deno.test('Response#create text drops the body for a not-modified status', async () => {
- const res = send.text('ignored', { status: 304 })
- assertEquals(res.status, 304)
- assertEquals(res.body, null)
- assertEquals(await res.text(), '')
-})
-
-Deno.test('Response#create text includes Set-Cookie values', () => {
- const cookieSend = Core.Response.create({}, ['x=1'], buildRedirect)
- const res = cookieSend.text('hello')
- assertEquals(res.headers.getSetCookie().length, 1)
-})
-
-Deno.test('Response#create text sets text/plain', async () => {
- const res = send.text('hello', { status: 200 })
- assertEquals(res.headers.get('Content-Type'), 'text/plain; charset=utf-8')
- assertEquals(await res.text(), 'hello')
-})
-
-Deno.test('Response#create text with empty string', async () => {
- const res = send.text('')
- assertEquals(res.headers.get('Content-Type'), 'text/plain; charset=utf-8')
- assertEquals(await res.text(), '')
-})
diff --git a/tests/helper.ts b/tests/helper.ts
new file mode 100644
index 0000000..9e4ed1a
--- /dev/null
+++ b/tests/helper.ts
@@ -0,0 +1,15 @@
+import * as Core from '@core/index.ts'
+
+export default class Helper {
+ static createTestContext(
+ url = 'http://localhost/',
+ requestInit?: RequestInit
+ ): Core.Context {
+ const request = new Request(url, requestInit)
+ return new Core.Context(request, new URL(url), null, undefined, undefined, null, () => {})
+ }
+
+ static okNext(): Promise {
+ return Promise.resolve(new Response('ok'))
+ }
+}
From 23de156423755bb2713ced8935ba76ebc2e51e98 Mon Sep 17 00:00:00 2001
From: NeaByteLab <209737579+NeaByteLab@users.noreply.github.com>
Date: Thu, 25 Jun 2026 22:14:42 +0700
Subject: [PATCH 07/40] refactor(core): simplify Handler errors and trim
Constant tables
- Add contentDisposition and control-char stripping to Handler
- Drop validation-reason plumbing from error response builders
- Rename isErrorWithStatus to isStatusError and accept a cause
- Restructure securityHeaders into header and default entries
- Trim content-type table and remove render budget constants
- Remove obsolete error, hardening, and helper test suites
---
src/core/Constant.ts | 324 ++++++++++-------------------
src/core/Handler.ts | 316 +++++++++++++----------------
tests/config/HumanError.test.ts | 62 ------
tests/core/Constant.test.ts | 85 +++++---
tests/core/Error.test.ts | 347 --------------------------------
tests/core/Handler.test.ts | 173 ++++++----------
tests/core/Hardening.test.ts | 78 -------
tests/core/Helper.test.ts | 60 ------
8 files changed, 360 insertions(+), 1085 deletions(-)
delete mode 100644 tests/config/HumanError.test.ts
delete mode 100644 tests/core/Error.test.ts
delete mode 100644 tests/core/Hardening.test.ts
delete mode 100644 tests/core/Helper.test.ts
diff --git a/src/core/Constant.ts b/src/core/Constant.ts
index 236e9f4..dd112da 100644
--- a/src/core/Constant.ts
+++ b/src/core/Constant.ts
@@ -1,16 +1,44 @@
import type * as Types from '@interfaces/index.ts'
-import * as Core from '@core/index.ts'
/**
- * Shared constants and framework singletons.
- * @description Centralizes all static data: encoders, regexes, defaults, maps.
+ * Framework wide constant values.
+ * @description Holds shared defaults, regexes, and lookup tables.
*/
export class Constant {
- /** Shared UTF-8 text decoder */
- static readonly decoder: TextDecoder = new Core.API.TextDecoder()
- /** Shared UTF-8 text encoder */
- static readonly encoder: TextEncoder = new Core.API.TextEncoder()
- /** HTML entity map for escaping */
+ /** Regex trimming leading and trailing spaces */
+ static readonly cookieTrimRegex = /^[ \t]+|[ \t]+$/g
+ /** Shared UTF-8 text decoder instance */
+ static readonly decoder: TextDecoder = new TextDecoder()
+ /** Default content type for unknown files */
+ static readonly defaultContentType = 'application/octet-stream'
+ /** Default worker pool size */
+ static readonly defaultPoolSize = 4
+ /** Default queue depth multiplier per worker */
+ static readonly defaultQueueFactor = 8
+ /** Default queue wait timeout in milliseconds */
+ static readonly defaultQueueWaitMs = 2000
+ /** Default session cookie option values */
+ static readonly defaultSessionOptions: Readonly = {
+ name: 'session',
+ httpOnly: true,
+ maxAge: 86400,
+ path: '/',
+ sameSite: 'Lax',
+ secure: false
+ }
+ /** Default worker task timeout in milliseconds */
+ static readonly defaultWorkerTaskTimeoutMs = 5000
+ /** Regex escaping disposition filename characters */
+ static readonly dispositionEscapeRegex = /[\\"]/g
+ /** Regex matching non-ASCII disposition characters */
+ static readonly dispositionNonAsciiRegex = /[\u0080-\u{10FFFF}]/u
+ /** Regex stripping directory path prefix */
+ static readonly dispositionPathRegex = /^.*[\\/]/
+ /** Template file extension for views */
+ static readonly dveExtension = '.dve'
+ /** Shared UTF-8 text encoder instance */
+ static readonly encoder: TextEncoder = new TextEncoder()
+ /** HTML entity replacements for escaping */
static readonly htmlEscapeMap: Readonly = {
'&': '&',
'<': '<',
@@ -18,70 +46,66 @@ export class Constant {
'"': '"',
"'": '''
}
- /** Matches characters needing HTML escaping */
+ /** Regex matching characters needing HTML escape */
static readonly htmlEscapeRegex = /[&<>"']/g
- /** Strips separators and control characters */
- static readonly sanitizeRegex = /^.*[\\/]|\p{Cc}/gu
- /** Matches backslash or double-quote characters */
- static readonly escapeRegex = /[\\\"]/g
- /** Matches any non-ASCII character */
- static readonly nonAsciiRegex = /[\u0080-\u{10FFFF}]/u
- /** Matches all non-ASCII characters globally */
- static readonly nonAsciiGlobalRegex = /[\u0080-\u{10FFFF}]/gu
- /** Dotted path regex for fast-path */
- static readonly simplePathRegex = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/
- /** Matches leading and trailing cookie whitespace */
- static readonly cookieTrimRegex = /^[ \t]+|[ \t]+$/g
- /** DVE template file extension */
- static readonly dveExtension = '.dve'
- /** Default worker pool size */
- static readonly defaultPoolSize = 4
- /** Default worker task timeout in ms */
- static readonly defaultWorkerTaskTimeoutMs = 5_000
- /** Default pending-task multiplier per worker slot */
- static readonly defaultQueueFactor = 8
- /** Default projected-wait deadline in ms */
- static readonly defaultQueueWaitMs = 2_000
- /** Null-body HTTP status codes */
- static readonly nullBodyStatuses: ReadonlySet = new Set([101, 204, 205, 304])
- /** Valid redirect status codes */
- static readonly redirectStatuses: ReadonlySet = new Set([301, 302, 303, 307, 308])
- /** Default max route parameter length */
+ /** Maximum allowed route parameter length */
static readonly maxParamLength = 1024
- /** Default max request URL length */
+ /** Maximum allowed request URL length */
static readonly maxUrlLength = 8192
- /** Default max #each iterations */
- static readonly defaultMaxIterations = 100_000
- /** Default max #each body executions per render */
- static readonly defaultMaxRenderIterations = 1_000_000
- /** Default max output characters per render */
- static readonly defaultMaxOutputSize = 5_000_000
- /** Maximum template include nesting depth */
- static readonly maxIncludeDepth = 64
- /** Route watcher debounce in milliseconds */
+ /** Status codes that forbid response bodies */
+ static readonly nullBodyStatuses: ReadonlySet = new Set([101, 204, 205, 304])
+ /** Content type for problem detail responses */
+ static readonly problemJsonContentType = 'application/problem+json'
+ /** Status codes treated as HTTP redirects */
+ static readonly redirectStatuses: ReadonlySet = new Set([301, 302, 303, 307, 308])
+ /** Route reload debounce in milliseconds */
static readonly routeDebounceMs = 150
- /** Template watcher debounce in milliseconds */
+ /** Template reload debounce in milliseconds */
static readonly templateDebounceMs = 100
- /** Default session cookie options */
- static readonly defaultSessionOptions: Types.SessionCookieOpts = {
- cookieName: 'session',
- maxAge: 86400,
- path: '/',
- sameSite: 'Lax',
- httpOnly: true,
- secure: true
+ /** Allowed route module file extensions */
+ static readonly allowedExtensions: readonly string[] = ['cjs', 'js', 'jsx', 'mjs', 'ts', 'tsx']
+ /** File extension to content type map */
+ static readonly contentTypes: Readonly = {
+ css: 'text/css; charset=utf-8',
+ csv: 'text/csv; charset=utf-8',
+ gif: 'image/gif',
+ htm: 'text/html; charset=utf-8',
+ html: 'text/html; charset=utf-8',
+ ico: 'image/x-icon',
+ jpeg: 'image/jpeg',
+ jpg: 'image/jpeg',
+ js: 'text/javascript; charset=utf-8',
+ json: 'application/json; charset=utf-8',
+ map: 'application/json; charset=utf-8',
+ mjs: 'text/javascript; charset=utf-8',
+ pdf: 'application/pdf',
+ png: 'image/png',
+ svg: 'image/svg+xml; charset=utf-8',
+ txt: 'text/plain; charset=utf-8',
+ wasm: 'application/wasm',
+ webp: 'image/webp',
+ woff: 'font/woff',
+ woff2: 'font/woff2',
+ xml: 'application/xml; charset=utf-8'
}
- /** Problem details JSON content type */
- static readonly problemJsonContentType = 'application/problem+json'
- /** Status code to error message */
- static readonly serverErrorMessages: Readonly<
- Partial>
- > = {
+ /** Supported HTTP request methods */
+ static readonly httpMethods: readonly Types.HttpMethod[] = [
+ 'DELETE',
+ 'GET',
+ 'HEAD',
+ 'OPTIONS',
+ 'PATCH',
+ 'POST',
+ 'PUT'
+ ]
+ /** Status code to reason phrase map */
+ static readonly serverErrorMessages: Readonly>> = {
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
405: 'Method Not Allowed',
+ 406: 'Not Acceptable',
408: 'Request Timeout',
409: 'Conflict',
410: 'Gone',
@@ -96,166 +120,22 @@ export class Constant {
503: 'Service Unavailable',
504: 'Gateway Timeout'
}
- /** Secure default values for security headers */
- static readonly securityHeaderDefaults: Readonly = {
- 'Cross-Origin-Opener-Policy': 'same-origin',
- 'Cross-Origin-Resource-Policy': 'same-origin',
- 'Origin-Agent-Cluster': '?1',
- 'Referrer-Policy': 'no-referrer',
- 'X-Content-Type-Options': 'nosniff',
- 'X-DNS-Prefetch-Control': 'off',
- 'X-Download-Options': 'noopen',
- 'X-Frame-Options': 'SAMEORIGIN',
- 'X-Permitted-Cross-Domain-Policies': 'none'
- }
- /** File extensions allowed for route modules */
- static readonly allowedExtensions: readonly Types.RouteFileExtension[] = [
- 'cjs',
- 'js',
- 'jsx',
- 'mjs',
- 'ts',
- 'tsx'
- ]
- /** Extension to MIME type map */
- static readonly contentTypes: Readonly = {
- html: 'text/html',
- htm: 'text/html',
- css: 'text/css',
- less: 'text/css',
- scss: 'text/css',
- sass: 'text/css',
- js: 'application/javascript',
- mjs: 'application/javascript',
- cjs: 'application/javascript',
- ts: 'application/typescript',
- tsx: 'application/typescript',
- jsx: 'application/javascript',
- json: 'application/json',
- jsonld: 'application/ld+json',
- ndjson: 'application/x-ndjson',
- map: 'application/json',
- geojson: 'application/geo+json',
- topojson: 'application/json',
- md: 'text/markdown',
- markdown: 'text/markdown',
- sh: 'application/x-sh',
- csh: 'application/x-csh',
- bash: 'text/plain',
- php: 'application/x-httpd-php',
- yaml: 'text/yaml',
- yml: 'text/yaml',
- toml: 'text/toml',
- ics: 'text/calendar',
- png: 'image/png',
- jpg: 'image/jpeg',
- jpeg: 'image/jpeg',
- gif: 'image/gif',
- webp: 'image/webp',
- svg: 'image/svg+xml',
- ico: 'image/x-icon',
- avif: 'image/avif',
- apng: 'image/apng',
- heif: 'image/heif',
- heic: 'image/heic',
- bmp: 'image/bmp',
- tiff: 'image/tiff',
- tif: 'image/tiff',
- ttf: 'font/ttf',
- otf: 'font/otf',
- woff: 'font/woff',
- woff2: 'font/woff2',
- ttc: 'font/collection',
- eot: 'application/vnd.ms-fontobject',
- pdf: 'application/pdf',
- epub: 'application/epub+zip',
- azw: 'application/vnd.amazon.ebook',
- doc: 'application/msword',
- docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
- xls: 'application/vnd.ms-excel',
- xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
- ppt: 'application/vnd.ms-powerpoint',
- pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
- odt: 'application/vnd.oasis.opendocument.text',
- ods: 'application/vnd.oasis.opendocument.spreadsheet',
- odp: 'application/vnd.oasis.opendocument.presentation',
- vsd: 'application/vnd.visio',
- mpkg: 'application/vnd.apple.installer+xml',
- xml: 'application/xml',
- xhtml: 'application/xhtml+xml',
- xul: 'application/vnd.mozilla.xul+xml',
- csv: 'text/csv',
- txt: 'text/plain',
- rtf: 'application/rtf',
- abw: 'application/x-abiword',
- bin: 'application/octet-stream',
- zip: 'application/zip',
- tar: 'application/x-tar',
- gz: 'application/gzip',
- bz: 'application/x-bzip',
- bz2: 'application/x-bzip2',
- xz: 'application/x-xz',
- rar: 'application/vnd.rar',
- arc: 'application/x-freearc',
- jar: 'application/java-archive',
- '7z': 'application/x-7z-compressed',
- mp4: 'video/mp4',
- m4v: 'video/mp4',
- mpeg: 'video/mpeg',
- webm: 'video/webm',
- avi: 'video/x-msvideo',
- mov: 'video/quicktime',
- mkv: 'video/x-matroska',
- wmv: 'video/x-ms-wmv',
- flv: 'video/x-flv',
- ogv: 'video/ogg',
- ogx: 'application/ogg',
- '3gp': 'video/3gpp',
- '3g2': 'video/3gpp2',
- mts: 'video/mp2t',
- mp3: 'audio/mpeg',
- ogg: 'audio/ogg',
- oga: 'audio/ogg',
- wav: 'audio/wav',
- flac: 'audio/flac',
- aac: 'audio/aac',
- m4a: 'audio/mp4',
- opus: 'audio/opus',
- weba: 'audio/webm',
- mid: 'audio/midi',
- midi: 'audio/midi',
- cda: 'application/x-cdf',
- glb: 'model/gltf-binary',
- gltf: 'model/gltf+json',
- wasm: 'application/wasm',
- manifest: 'application/manifest+json',
- webmanifest: 'application/manifest+json',
- serviceworker: 'application/javascript'
- }
- /** Security header option to name */
+ /** Security header names with default values */
static readonly securityHeaders = {
- contentSecurityPolicy: 'Content-Security-Policy',
- crossOriginEmbedderPolicy: 'Cross-Origin-Embedder-Policy',
- crossOriginOpenerPolicy: 'Cross-Origin-Opener-Policy',
- crossOriginResourcePolicy: 'Cross-Origin-Resource-Policy',
- originAgentCluster: 'Origin-Agent-Cluster',
- referrerPolicy: 'Referrer-Policy',
- strictTransportSecurity: 'Strict-Transport-Security',
- xContentTypeOptions: 'X-Content-Type-Options',
- xDnsPrefetchControl: 'X-DNS-Prefetch-Control',
- xDownloadOptions: 'X-Download-Options',
- xFrameOptions: 'X-Frame-Options',
- xPermittedCrossDomainPolicies: 'X-Permitted-Cross-Domain-Policies',
- xPoweredBy: 'X-Powered-By'
- } as const satisfies Types.StringRecord
- /** HTTP methods used for route registration */
- static readonly httpMethods: readonly Types.HttpMethod[] = [
- 'DELETE',
- 'GET',
- 'HEAD',
- 'OPTIONS',
- 'PATCH',
- 'POST',
- 'PUT'
- ]
+ contentSecurityPolicy: { header: 'Content-Security-Policy', default: null },
+ crossOriginEmbedderPolicy: { header: 'Cross-Origin-Embedder-Policy', default: null },
+ crossOriginOpenerPolicy: { header: 'Cross-Origin-Opener-Policy', default: 'same-origin' },
+ crossOriginResourcePolicy: { header: 'Cross-Origin-Resource-Policy', default: 'same-origin' },
+ originAgentCluster: { header: 'Origin-Agent-Cluster', default: '?1' },
+ referrerPolicy: { header: 'Referrer-Policy', default: 'no-referrer' },
+ strictTransportSecurity: { header: 'Strict-Transport-Security', default: null },
+ xContentTypeOptions: { header: 'X-Content-Type-Options', default: 'nosniff' },
+ xDnsPrefetchControl: { header: 'X-DNS-Prefetch-Control', default: 'off' },
+ xDownloadOptions: { header: 'X-Download-Options', default: 'noopen' },
+ xFrameOptions: { header: 'X-Frame-Options', default: 'SAMEORIGIN' },
+ xPermittedCrossDomainPolicies: {
+ header: 'X-Permitted-Cross-Domain-Policies',
+ default: 'none'
+ }
+ } as const
}
diff --git a/src/core/Handler.ts b/src/core/Handler.ts
index 8e506f7..dc2fa3d 100644
--- a/src/core/Handler.ts
+++ b/src/core/Handler.ts
@@ -1,36 +1,16 @@
import type * as Types from '@interfaces/index.ts'
-import type { Context } from '@core/Context.ts'
import * as Core from '@core/index.ts'
/**
- * Handler utilities for routing layers.
- * @description Static helpers used by routing, middleware, and context layers.
+ * HTTP error and header helpers.
+ * @description Builds error responses, headers, and status errors.
*/
export class Handler {
- /** Well-known framework state keys */
- static readonly stateKeys: Types.StateKeysMap = {
- view: Handler.stateKey('view'),
- worker: Handler.stateKey('worker'),
- session: Handler.stateKey('session'),
- setSession: Handler.stateKey<(data: Types.DataRecord) => Promise>('setSession'),
- clearSession: Handler.stateKey<() => void>('clearSession'),
- validated: Handler.stateKey('validated')
- } as const
- /** Reserved framework state key names, not writable by public setState */
- static readonly reservedStateKeys: ReadonlySet = new Set([
- 'view',
- 'worker',
- 'session',
- 'setSession',
- 'clearSession',
- 'validated'
- ])
-
/**
- * Append Set-Cookie values to headers.
- * @description Appends each cookie value as a Set-Cookie header.
- * @param headers - Target Headers instance
- * @param cookieValues - Cookie values to append
+ * Append Set-Cookie header values.
+ * @description Adds each cookie value as separate header.
+ * @param headers - Headers instance to mutate
+ * @param cookieValues - Cookie header strings to append
*/
static appendCookies(headers: Headers, cookieValues: readonly string[]): void {
for (const cookieValue of cookieValues) {
@@ -39,13 +19,13 @@ export class Handler {
}
/**
- * Assert a value is a positive finite number.
- * @description Single validator for positive-number construction options.
- * @param value - Value to validate
- * @param label - Option name surfaced in the error message
- * @param unit - Optional unit suffix, e.g. "milliseconds"
- * @returns The validated positive finite number
- * @throws {Deno.errors.InvalidData} When the value is non-finite or <= 0
+ * Assert value is positive finite.
+ * @description Throws labeled error when value is invalid.
+ * @param value - Number value to validate
+ * @param label - Label used in error message
+ * @param unit - Optional unit used in message
+ * @returns Same value when valid
+ * @throws When value is not positive finite
*/
static assertPositiveFinite(value: number, label: string, unit?: string): number {
if (!Number.isFinite(value) || value <= 0) {
@@ -58,25 +38,25 @@ export class Handler {
}
/**
- * Build error response with format.
- * @description Tries middleware then JSON or HTML, masks error messages.
- * @param ctx - Request context
- * @param statusCode - HTTP status code
- * @param error - Thrown error
- * @param errorMiddleware - Optional custom handler
- * @returns Error response
+ * Build error response with middleware.
+ * @description Uses middleware response when valid otherwise default.
+ * @param ctx - Request context instance
+ * @param statusCode - HTTP status code to send
+ * @param error - Caught error instance
+ * @param errorMiddleware - Optional error middleware handler
+ * @returns Promise resolving to error response
*/
static async buildResponse(
- ctx: Context,
+ ctx: Core.Context,
statusCode: number,
error: globalThis.Error,
errorMiddleware: Types.ErrorMiddleware | null
): Promise {
if (errorMiddleware) {
const customResponse = await errorMiddleware(ctx, {
- url: ctx.url,
- method: ctx.request.method,
- pathname: ctx.pathname,
+ url: ctx.get.url().href,
+ method: ctx.get.method(),
+ pathname: ctx.get.pathname(),
statusCode,
error
})
@@ -88,14 +68,43 @@ export class Handler {
}
/**
- * Create StatusError with status code.
- * @description Produces Error with immutable statusCode property attached.
- * @param statusCode - HTTP status code
- * @param message - Error message
- * @returns Error with statusCode property
+ * Build content disposition header value.
+ * @description Sanitizes filename and adds UTF-8 fallback.
+ * @param filename - Download filename to encode
+ * @returns Content-Disposition header value
+ */
+ static contentDisposition(filename: string): string {
+ const baseName = filename.replace(Core.Constant.dispositionPathRegex, '')
+ const safeName = Handler.stripControlChars(baseName) || 'download'
+ const asciiName = Array.from(safeName, (char) => char.codePointAt(0)! > 127 ? '_' : char).join(
+ ''
+ )
+ .replace(Core.Constant.dispositionEscapeRegex, (match) => `\\${match}`)
+ const asciiFallback = asciiName || 'download'
+ let headerValue = `attachment; filename="${asciiFallback}"`
+ if (Core.Constant.dispositionNonAsciiRegex.test(safeName)) {
+ headerValue += `; filename*=UTF-8''${encodeURIComponent(safeName)}`
+ }
+ return headerValue
+ }
+
+ /**
+ * Create error with status code.
+ * @description Attaches non-writable status code property.
+ * @param statusCode - HTTP status code to attach
+ * @param message - Error message text
+ * @param cause - Optional cause detail strings
+ * @returns Error carrying status code
*/
- static createStatusError(statusCode: number, message: string): Types.StatusError {
- const error = new Error(message) as Types.StatusError
+ static createStatusError(
+ statusCode: number,
+ message: string,
+ cause?: readonly string[]
+ ): Types.StatusError {
+ const error =
+ (cause === undefined
+ ? new Error(message)
+ : new Error(message, { cause })) as Types.StatusError
Object.defineProperty(error, 'statusCode', {
value: statusCode,
writable: false,
@@ -106,11 +115,11 @@ export class Handler {
}
/**
- * Minimal HTML error page.
- * @description Returns simple HTML with status and escaped message.
- * @param statusCode - Status code and title
- * @param message - Escaped message body
- * @returns HTML string
+ * Build default error HTML page.
+ * @description Escapes message into simple HTML document.
+ * @param statusCode - HTTP status code to show
+ * @param message - Error message text
+ * @returns HTML error page string
*/
static defaultErrorHtml(statusCode: number, message: string): string {
const escapedMessage = Handler.escapeHtml(message)
@@ -126,49 +135,48 @@ export class Handler {
}
/**
- * Build error response by Accept header.
- * @description Single source of truth for safe error responses.
- * @param ctx - Request context
- * @param statusCode - HTTP status code
- * @returns Error response with masked message
+ * Build negotiated error response.
+ * @description Sends JSON or HTML based on accept.
+ * @param ctx - Request context instance
+ * @param statusCode - HTTP status code to send
+ * @returns Negotiated error response
*/
- static errorResponse(ctx: Context, statusCode: number): globalThis.Response {
+ static errorResponse(ctx: Core.Context, statusCode: number): globalThis.Response {
const errorMessage = Handler.safeMessage(statusCode)
- const wantsJson = Handler.wantsJson(ctx.request.headers)
- const reasons = Handler.safeReasons(ctx[Core.InternalContext].getFrameworkError())
+ const wantsJson = Handler.wantsJson(ctx.get.request().headers)
try {
if (wantsJson) {
- return ctx.send.json(Handler.problemDetails(statusCode, ctx.pathname, undefined, reasons), {
- status: statusCode,
+ return ctx.send.json(Handler.problemDetails(statusCode, ctx.get.pathname()), {
+ status: statusCode as Types.HttpStatusCode,
headers: { 'Content-Type': Core.Constant.problemJsonContentType }
})
}
return ctx.send.html(Handler.defaultErrorHtml(statusCode, errorMessage), {
- status: statusCode
+ status: statusCode as Types.HttpStatusCode
})
} catch {
- return Handler.safeFallbackResponse(ctx, statusCode, errorMessage, wantsJson, reasons)
+ return Handler.negotiatedResponse(statusCode, errorMessage, wantsJson, ctx.get.pathname())
}
}
/**
* Escape HTML special characters.
- * @description Replaces &, <, >, ", ' with HTML entities.
- * @param text - Raw string
- * @returns Escaped string safe for HTML content
+ * @description Replaces unsafe characters with HTML entities.
+ * @param text - Text to escape
+ * @returns Escaped HTML safe text
*/
static escapeHtml(text: string): string {
return text.replace(Core.Constant.htmlEscapeRegex, (ch) => Core.Constant.htmlEscapeMap[ch]!)
}
/**
- * Extract status code from error.
- * @description Prefers statusCode property, then Deno error class, else 500.
- * @param error - Unknown value from catch block
- * @returns Object with statusCode and Error instance
+ * Extract status and error value.
+ * @description Maps Deno errors to HTTP status codes.
+ * @param error - Unknown thrown value
+ * @returns Status code and error pair
*/
static extractError(error: unknown): Types.ExtractedError {
- if (Handler.isErrorWithStatus(error)) {
+ if (Handler.isStatusError(error)) {
return { statusCode: error.statusCode, error }
}
if (error instanceof Error) {
@@ -178,53 +186,52 @@ export class Handler {
}
/**
- * Check path is existing directory.
- * @description Returns false when path is missing or not directory.
- * @param resolvedDir - Absolute directory path
- * @returns True when the path exists and is a directory
+ * Check path is a directory.
+ * @description Returns false when stat call fails.
+ * @param path - Filesystem path to check
+ * @returns True when path is directory
*/
- static isDirectory(resolvedDir: string): boolean {
+ static isDirectory(path: string): boolean {
try {
- return Deno.statSync(resolvedDir).isDirectory
+ return Deno.statSync(path).isDirectory
} catch {
return false
}
}
/**
- * Narrow a value to a status-bearing Error.
- * @description True for Errors whose statusCode is 400-599.
- * @param value - Unknown value from a catch block
- * @returns True when value is an Error with an in-range statusCode
+ * Check value carries HTTP status.
+ * @description Validates error with status in client range.
+ * @param value - Unknown value to inspect
+ * @returns True when value is status error
*/
- static isErrorWithStatus(value: unknown): value is Types.StatusError {
+ static isStatusError(value: unknown): value is Types.StatusError {
if (!(value instanceof Error) || !('statusCode' in value)) {
return false
}
- const statusValue = (value as Types.StatusCodeCarrier).statusCode
+ const statusValue = (value as Types.StatusCarrier).statusCode
return typeof statusValue === 'number' && statusValue >= 400 && statusValue < 600
}
/**
- * Build a content-negotiated error response.
- * @description Single site for JSON or HTML error bodies.
- * @param statusCode - HTTP status code to emit
- * @param message - Safe masked message
- * @param wantsJson - Whether the client prefers JSON
- * @param pathname - Optional request pathname included in JSON bodies
- * @returns Error response with security headers and the negotiated body
+ * Build response without context helpers.
+ * @description Produces JSON or HTML error directly.
+ * @param statusCode - HTTP status code to send
+ * @param message - Error message text
+ * @param wantsJson - Send JSON when true
+ * @param pathname - Optional request path instance
+ * @returns Negotiated error response
*/
static negotiatedResponse(
statusCode: number,
message: string,
wantsJson: boolean,
- pathname?: string,
- reasons?: readonly string[]
+ pathname?: string
): globalThis.Response {
- const headers = new Core.API.Headers(Core.Constant.securityHeaderDefaults)
+ const headers = new Core.API.Headers()
if (wantsJson) {
headers.set('Content-Type', Core.Constant.problemJsonContentType)
- const body = Handler.problemDetails(statusCode, pathname, message, reasons)
+ const body = Handler.problemDetails(statusCode, pathname, message)
return new Core.API.Response(Core.API.jsonStringify(body), { status: statusCode, headers })
}
headers.set('Content-Type', 'text/html; charset=utf-8')
@@ -235,36 +242,31 @@ export class Handler {
}
/**
- * Build structured error problem details.
- * @description Returns problem body with type, title, status, optional instance.
- * @param statusCode - HTTP status code
- * @param pathname - Optional request pathname as instance
- * @param title - Optional title overriding safe message
- * @param reasons - Optional validation reasons added as errors
- * @returns Problem details object
+ * Build problem details object.
+ * @description Includes instance path when provided.
+ * @param statusCode - HTTP status code to report
+ * @param pathname - Optional instance path value
+ * @param title - Optional problem title text
+ * @returns Problem details payload object
*/
static problemDetails(
statusCode: number,
pathname?: string,
- title?: string,
- reasons?: readonly string[]
+ title?: string
): Types.ProblemDetails {
const base: Types.ProblemDetails = {
type: 'about:blank',
title: title ?? Handler.safeMessage(statusCode),
status: statusCode
}
- const withInstance = pathname === undefined ? base : { ...base, instance: pathname }
- return reasons !== undefined && reasons.length > 0
- ? { ...withInstance, errors: reasons }
- : withInstance
+ return pathname === undefined ? base : { ...base, instance: pathname }
}
/**
- * Resolve safe message for status.
- * @description Returns known message or generic fallback for status.
- * @param statusCode - HTTP status code
- * @returns Safe user-facing error message
+ * Resolve safe status message text.
+ * @description Falls back by client or server range.
+ * @param statusCode - HTTP status code to map
+ * @returns Reason phrase for status
*/
static safeMessage(statusCode: number): string {
return (
@@ -274,42 +276,27 @@ export class Handler {
}
/**
- * Extract safe reasons from error.
- * @description Returns string causes only for 422 status errors.
- * @param error - Error to inspect, or null
- * @returns Reason strings, or undefined when none
+ * Strip control characters from text.
+ * @description Removes characters below 32 and delete.
+ * @param text - Text to sanitize
+ * @returns Text without control characters
*/
- static safeReasons(error: Error | null): readonly string[] | undefined {
- if (
- error === null ||
- !Handler.isErrorWithStatus(error) ||
- error.statusCode !== 422 ||
- !Array.isArray(error.cause)
- ) {
- return undefined
+ static stripControlChars(text: string): string {
+ let result = ''
+ for (const char of text) {
+ const code = char.codePointAt(0)!
+ if (code >= 32 && code !== 127) {
+ result += char
+ }
}
- const reasons = (error.cause as readonly unknown[]).filter(
- (reason): reason is string => typeof reason === 'string'
- )
- return reasons.length > 0 ? reasons : undefined
+ return result
}
/**
- * Create a branded state key.
- * @description Returns type-branded string for compile-time safety.
- * @template T - The value type this key maps to
- * @param key - Raw string key
- * @returns Branded StateKey
- */
- static stateKey(key: string): Types.StateKey {
- return key as Types.StateKey
- }
-
- /**
- * Convert HeadersInit to record.
- * @description Returns plain string record from any HeadersInit variant.
- * @param init - Headers, array, or object
- * @returns Key-value string record
+ * Convert headers init to record.
+ * @description Normalizes Headers, array, or object input.
+ * @param init - Optional headers init value
+ * @returns String record of header pairs
*/
static toRecord(init?: HeadersInit): Types.StringRecord {
if (!init) {
@@ -325,10 +312,10 @@ export class Handler {
}
/**
- * Check client prefers JSON response.
- * @description Returns true when Accept header includes application/json.
- * @param headers - Request headers
- * @returns True when JSON is preferred
+ * Check accept header wants JSON.
+ * @description Detects JSON or problem JSON accept values.
+ * @param headers - Request headers instance
+ * @returns True when client accepts JSON
*/
static wantsJson(headers: Headers): boolean {
const accept = headers.get('accept')
@@ -339,10 +326,10 @@ export class Handler {
}
/**
- * Map standard error class to status.
- * @description Whitelists unambiguous Deno error types, else returns 500.
- * @param error - Error instance to classify
- * @returns Canonical HTTP status code
+ * Map Deno error to status.
+ * @description Returns specific code or 500 default.
+ * @param error - Error instance to inspect
+ * @returns HTTP status code for error
*/
private static denoErrorStatus(error: Error): number {
if (error instanceof Deno.errors.NotFound) {
@@ -365,23 +352,4 @@ export class Handler {
}
return 500
}
-
- /**
- * Build a guaranteed-valid error response.
- * @description Emits baseline security headers when send path fails.
- * @param ctx - Request context
- * @param statusCode - HTTP status code
- * @param message - Safe masked message
- * @param wantsJson - Whether client prefers JSON
- * @returns Safe error response
- */
- private static safeFallbackResponse(
- ctx: Context,
- statusCode: number,
- message: string,
- wantsJson: boolean,
- reasons?: readonly string[]
- ): globalThis.Response {
- return Handler.negotiatedResponse(statusCode, message, wantsJson, ctx.pathname, reasons)
- }
}
diff --git a/tests/config/HumanError.test.ts b/tests/config/HumanError.test.ts
deleted file mode 100644
index 0643536..0000000
--- a/tests/config/HumanError.test.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { assertEquals, assertThrows } from '@std/assert'
-import * as Core from '@core/index.ts'
-import * as Middleware from '@middleware/index.ts'
-import * as Routing from '@routing/index.ts'
-
-const echoWorkerUrl = import.meta.resolve('@tests/fixtures/echo_worker.ts')
-
-Deno.test('BodyLimit with limit 0 is rejected at creation', () => {
- assertThrows(
- () => Middleware.Mware.bodyLimit({ limit: 0 }),
- Deno.errors.InvalidData,
- 'positive finite'
- )
-})
-
-Deno.test('BodyLimit with negative limit is rejected at creation', () => {
- assertThrows(
- () => Middleware.Mware.bodyLimit({ limit: -1 }),
- Deno.errors.InvalidData,
- 'positive finite'
- )
-})
-
-Deno.test('Router constructor accepts poolSize 0 as 1', async () => {
- const router = new Routing.Router({
- routesDir: './routes',
- worker: { scriptURL: echoWorkerUrl, poolSize: 0 }
- })
- const handler = (router as unknown as { handler: unknown }).handler as {
- workerPool?: Core.Worker
- }
- assertEquals(handler.workerPool !== undefined, true)
- try {
- const result = await handler.workerPool!.run('ok')
- assertEquals(result, 'ok')
- } finally {
- handler.workerPool!.terminate()
- }
-})
-
-Deno.test('Router constructor throws on invalid worker config', () => {
- assertThrows(() => {
- new Routing.Router({
- routesDir: './routes',
- worker: { scriptURL: 'not-a-valid-worker-specifier', poolSize: 1 }
- })
- })
-})
-
-Deno.test('Worker#createPool with poolSize 0 creates working pool', async () => {
- const pool = Core.Worker.createPool({ scriptURL: echoWorkerUrl, poolSize: 0 })
- try {
- const result = await pool.run('hello')
- assertEquals(result, 'hello')
- } finally {
- pool.terminate()
- }
-})
-
-Deno.test('Worker#createPool with poolSize 0 uses 1', () => {
- assertThrows(() => Core.Worker.createPool({ scriptURL: 'not-a-valid-worker-specifier' }))
-})
diff --git a/tests/core/Constant.test.ts b/tests/core/Constant.test.ts
index de68980..1a61bc1 100644
--- a/tests/core/Constant.test.ts
+++ b/tests/core/Constant.test.ts
@@ -1,35 +1,60 @@
import { assertEquals } from '@std/assert'
import * as Core from '@core/index.ts'
-Deno.test('Constant#allowedExtensions contains expected route extensions', () => {
- assertEquals(Core.Constant.allowedExtensions.includes('ts'), true)
- assertEquals(Core.Constant.allowedExtensions.includes('tsx'), true)
- assertEquals(Core.Constant.allowedExtensions.includes('js'), true)
- assertEquals(Core.Constant.allowedExtensions.includes('jsx'), true)
- assertEquals(Core.Constant.allowedExtensions.includes('mjs'), true)
- assertEquals(Core.Constant.allowedExtensions.includes('cjs'), true)
- assertEquals(Core.Constant.allowedExtensions.length, 6)
-})
-
-Deno.test('Constant#contentTypes has common MIME types', () => {
- assertEquals(Core.Constant.contentTypes['html'], 'text/html')
- assertEquals(Core.Constant.contentTypes['json'], 'application/json')
+Deno.test('Constant content types map known extensions', () => {
+ assertEquals(Core.Constant.contentTypes['html'], 'text/html; charset=utf-8')
+ assertEquals(Core.Constant.contentTypes['css'], 'text/css; charset=utf-8')
assertEquals(Core.Constant.contentTypes['png'], 'image/png')
- assertEquals(Core.Constant.contentTypes['js'], 'application/javascript')
- assertEquals(Core.Constant.contentTypes['txt'], 'text/plain')
- assertEquals(Core.Constant.contentTypes['pdf'], 'application/pdf')
- assertEquals(Core.Constant.contentTypes['css'], 'text/css')
- assertEquals(Core.Constant.contentTypes['svg'], 'image/svg+xml')
-})
-
-Deno.test('Constant#httpMethods contains standard HTTP methods', () => {
- assertEquals(Core.Constant.httpMethods, [
- 'DELETE',
- 'GET',
- 'HEAD',
- 'OPTIONS',
- 'PATCH',
- 'POST',
- 'PUT'
- ])
+ assertEquals(Core.Constant.defaultContentType, 'application/octet-stream')
+})
+
+Deno.test('Constant default session options use safe defaults', () => {
+ assertEquals(Core.Constant.defaultSessionOptions.name, 'session')
+ assertEquals(Core.Constant.defaultSessionOptions.httpOnly, true)
+ assertEquals(Core.Constant.defaultSessionOptions.sameSite, 'Lax')
+})
+
+Deno.test('Constant exposes default numeric limits', () => {
+ assertEquals(Core.Constant.maxUrlLength, 8192)
+ assertEquals(Core.Constant.maxParamLength, 1024)
+ assertEquals(Core.Constant.defaultPoolSize, 4)
+ assertEquals(Core.Constant.defaultQueueFactor, 8)
+ assertEquals(Core.Constant.defaultQueueWaitMs, 2000)
+ assertEquals(Core.Constant.defaultWorkerTaskTimeoutMs, 5000)
+})
+
+Deno.test('Constant html escape map covers unsafe characters', () => {
+ assertEquals(Core.Constant.htmlEscapeMap['&'], '&')
+ assertEquals(Core.Constant.htmlEscapeMap['<'], '<')
+ assertEquals(Core.Constant.htmlEscapeMap['>'], '>')
+ assertEquals(Core.Constant.htmlEscapeMap['"'], '"')
+ assertEquals(Core.Constant.htmlEscapeMap["'"], ''')
+})
+
+Deno.test('Constant http methods include common verbs', () => {
+ assertEquals(Core.Constant.httpMethods.includes('GET'), true)
+ assertEquals(Core.Constant.httpMethods.includes('POST'), true)
+ assertEquals(Core.Constant.httpMethods.includes('DELETE'), true)
+})
+
+Deno.test('Constant null body and redirect status sets', () => {
+ assertEquals(Core.Constant.nullBodyStatuses.has(204), true)
+ assertEquals(Core.Constant.nullBodyStatuses.has(200), false)
+ assertEquals(Core.Constant.redirectStatuses.has(302), true)
+ assertEquals(Core.Constant.redirectStatuses.has(200), false)
+})
+
+Deno.test('Constant security headers carry defaults', () => {
+ assertEquals(Core.Constant.securityHeaders.xContentTypeOptions.default, 'nosniff')
+ assertEquals(Core.Constant.securityHeaders.xFrameOptions.default, 'SAMEORIGIN')
+})
+
+Deno.test('Constant server error messages map status codes', () => {
+ assertEquals(Core.Constant.serverErrorMessages[404], 'Not Found')
+ assertEquals(Core.Constant.serverErrorMessages[500], 'Internal Server Error')
+})
+
+Deno.test('Constant shared encoder and decoder are usable', () => {
+ const bytes = Core.Constant.encoder.encode('hi')
+ assertEquals(Core.Constant.decoder.decode(bytes), 'hi')
})
diff --git a/tests/core/Error.test.ts b/tests/core/Error.test.ts
deleted file mode 100644
index 7576010..0000000
--- a/tests/core/Error.test.ts
+++ /dev/null
@@ -1,347 +0,0 @@
-import { assertEquals } from '@std/assert'
-import * as Core from '@core/index.ts'
-
-Deno.test('Error#buildResponse 500 does not leak error message', async () => {
- const request = new Request('http://localhost/', {
- headers: new Headers({ Accept: 'application/json' })
- })
- const ctx = new Core.Context(request, new URL('http://localhost/'), {})
- const res = await Core.Handler.buildResponse(
- ctx,
- 500,
- new globalThis.Error('Connection to ************************ failed'),
- null
- )
- const body = (await res.json()) as { title: string }
- assertEquals(body.title, 'Internal Server Error')
- assertEquals(JSON.stringify(body).includes('password'), false)
-})
-
-Deno.test('Error#buildResponse HTML output escapes message content', async () => {
- const request = new Request('http://localhost/')
- const ctx = new Core.Context(request, new URL('http://localhost/'), {})
- const res = await Core.Handler.buildResponse(ctx, 404, new globalThis.Error('irrelevant'), null)
- const html = await res.text()
- assertEquals(html.includes('