From a08b1cc7a9fc77be646b6bf4c79b8c6c64839b74 Mon Sep 17 00:00:00 2001 From: NeaByteLab <209737579+NeaByteLab@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:38:57 +0700 Subject: [PATCH 01/40] refactor(editor): remove bundled editor extension - Remove editor README and packaged VSIX - Remove language configuration, package manifest, and snippets - Remove tmLanguage grammar for dve files --- editor/README.md | 97 --------- editor/dve/README.md | 250 ------------------------ editor/dve/dve-language-0.1.0.vsix | Bin 7364 -> 0 bytes editor/dve/language-configuration.json | 19 -- editor/dve/package.json | 45 ----- editor/dve/snippets/dve.code-snippets | 61 ------ editor/dve/syntaxes/dve.tmLanguage.json | 141 ------------- 7 files changed, 613 deletions(-) delete mode 100644 editor/README.md delete mode 100644 editor/dve/README.md delete mode 100644 editor/dve/dve-language-0.1.0.vsix delete mode 100644 editor/dve/language-configuration.json delete mode 100644 editor/dve/package.json delete mode 100644 editor/dve/snippets/dve.code-snippets delete mode 100644 editor/dve/syntaxes/dve.tmLanguage.json diff --git a/editor/README.md b/editor/README.md deleted file mode 100644 index 600d266..0000000 --- a/editor/README.md +++ /dev/null @@ -1,97 +0,0 @@ -# Editor Tooling - -Editor support for Deserve, including syntax highlighting for Deserve View Engine (DVE) templates. - -## Table of Contents - -- [DVE (Deserve View Engine)](#dve-deserve-view-engine) -- [Example: Use DVE in Deserve](#example-use-dve-in-deserve) - - [Project Structure](#project-structure) - - [1) Add Templates](#1-add-templates) - - [2) Configure Router](#2-configure-router) - - [3) Render in a Route](#3-render-in-a-route) -- [Syntax Highlighting (Cursor / VS Code / Trae)](#syntax-highlighting-cursor--vs-code--trae) - -## DVE (Deserve View Engine) - -DVE is Deserve's built-in view engine for rendering `.dve` templates. - -## Example: Use DVE in Deserve - -### Project Structure - -``` -. -├── main.ts -├── routes/ -│ └── index.ts -└── views/ - ├── index.dve - └── partials/ - └── header.dve -``` - -### 1) Add Templates - -Create `views/index.dve`: - -```txt -{{> partials/header.dve}} -Hello {{ user?.name ?? 'Guest' }}. -``` - -Create `views/partials/header.dve`: - -```txt -

Welcome

-``` - -### 2) Configure Router - -Enable DVE by setting `viewsDir` when the router is created. - -```ts -import { Router } from '@neabyte/deserve' - -const router = new Router({ - routesDir: './routes', - viewsDir: './views' -}) - -await router.serve(8000) -``` - -### 3) Render in a Route - -Create `routes/index.ts`: - -```ts -import type { Context } from '@neabyte/deserve' - -export async function GET(ctx: Context) { - return await ctx.render('index', { user: { name: 'Nea' } }) -} -``` - -Run the server and open `http://localhost:8000` to see the rendered page. - -## Syntax Highlighting (Cursor / VS Code / Trae) - -Deserve ships a local DVE extension package at `editor/dve/dve-language-0.1.0.vsix`. - -Install it with an editor CLI: - -```bash -# Trae -trae --install-extension ./dve/dve-language-0.1.0.vsix --force - -# VS Code -code --install-extension ./dve/dve-language-0.1.0.vsix --force - -# Cursor -cursor --install-extension ./dve/dve-language-0.1.0.vsix --force -``` - -After installing, reload the editor window and open any `.dve` file, where HTML stays the base syntax with the embedded DVE tags highlighted on top. - -- **DVE syntax reference**: See [`editor/dve/README.md`](dve/README.md) diff --git a/editor/dve/README.md b/editor/dve/README.md deleted file mode 100644 index 9b0bd66..0000000 --- a/editor/dve/README.md +++ /dev/null @@ -1,250 +0,0 @@ -# DVE Grammar - -A short, friendly tour of the Deserve `.dve` template syntax that reads from top to bottom, so the first open of a `.dve` file feels easy instead of intimidating. - -- **Editor tooling overview**: See [`editor/README.md`](../README.md) - -## Table of Contents - -- [Install Local VSIX](#install-local-vsix) -- [Start Here](#start-here) -- [Variables](#variables) -- [Raw Output (Unescaped)](#raw-output-unescaped) -- [Include](#include) -- [If / Else](#if--else) -- [Each](#each) -- [Each Metadata](#each-metadata) -- [Expressions](#expressions) -- [Operator Reference](#operator-reference) -- [Snippets](#snippets) -- [Advanced Examples](#advanced-examples) -- [What DVE Does Not Do](#what-dve-does-not-do) -- [Editor Scope Mapping](#editor-scope-mapping) - -## Install Local VSIX - -This folder ships a prebuilt VSIX package, so nothing needs to be built first: - -```txt -dve-language-0.1.0.vsix -``` - -Install it from this directory with an editor CLI: - -```bash -# VS Code -code --install-extension ./dve-language-0.1.0.vsix --force - -# Cursor -cursor --install-extension ./dve-language-0.1.0.vsix --force - -# Trae -trae --install-extension ./dve-language-0.1.0.vsix --force -``` - -Reload the editor after installing, and since DVE builds on HTML syntax the `.dve` files keep full HTML highlighting with the template tags layered on top. - -## Start Here - -The whole language comes down to two tags, and once those land the rest is just small variations. - -A `{{ ... }}` tag **shows a value** while a `{{#... }} ... {{/... }}` tag wraps a **block** like an if or a loop, and everything further down builds on those two shapes. - -```txt -Hello {{ user?.name ?? 'Guest' }}. -{{#if user?.isAdmin}}ADMIN{{else}}USER{{/if}} -``` - -## Variables - -A value wrapped in double braces is printed onto the page, and DVE escapes HTML by default so user input can never sneak in markup or open an injection hole. - -```txt -Hello {{ name }}. -``` - -## Raw Output (Unescaped) - -Triple braces print the value as-is with no escaping, which is meant only for HTML that is already known to be safe. - -```txt -{{{ trustedHtml }}} -``` - -## Include - -A repeated piece of markup can live in its own file and get pulled in with an include, where the path is resolved relative to the configured `viewsDir`. - -```txt -{{> partials/header.dve}} -``` - -## If / Else - -An `#if` block renders its body only when the condition is truthy and an optional `else` covers the other case, and every `#if` must be closed with a matching `/if` or DVE reports the block as unclosed. - -```txt -{{#if ok}}YES{{else}}NO{{/if}} -``` - -## Each - -An `#each` block walks an array and `as` names the current item, and leaving the name out falls back to `item`. - -```txt -{{#each items as item}}{{ item }},{{/each}} -``` - -## Each Metadata - -Inside an `#each` block four helpers are available for free, so the loop position never has to be tracked by hand: - -- `@index` — current position, starting at 0 -- `@first` — true on the first item -- `@last` — true on the last item -- `@length` — total number of items - -```txt -{{#each items as item}}({{ @index }}/{{ @length }} {{#if @first}}F{{else}}-{{/if}}{{#if @last}}L{{else}}-{{/if}}={{ item }});{{/each}} -``` - -## Expressions - -Any `{{ ... }}` tag accepts a small JavaScript-like expression, so a value can be read, given a fallback, compared, or run through a little math all in one place. - -```txt -Hello {{ user?.name ?? 'Guest' }}. -Total {{ price * quantity }} -{{#if age >= 18}}Adult{{else}}Minor{{/if}} -``` - -A few behaviours worth knowing: - -- A dotted path like `user.profile.name` reads nested values, and missing data along the way resolves to `undefined` -- Both `.` and `?.` return `undefined` when the object is missing, so a deep lookup never throws -- Strings use `"double"` or `'single'` quotes and understand the `\n`, `\t`, `\r` escapes -- Numbers can be decimals or exponents like `2.5` or `1e3` - -## Operator Reference - -Everything DVE understands, lowest precedence at the top down to highest at the bottom, and anything not on this list is rejected by the parser on purpose. - -| Group | Operators | Example | -| -------------- | --------------------------------------------------- | -------------------------- | -| Ternary | `? :` | `{{ ok ? 'yes' : 'no' }}` | -| Nullish | `??` | `{{ name ?? 'Guest' }}` | -| Logical OR | `\|\|` | `{{ a \|\| b }}` | -| Logical AND | `&&` | `{{ a && b }}` | -| Equality | `===` `!==` `==` `!=` | `{{ role === 'admin' }}` | -| Relational | `>` `<` `>=` `<=` | `{{ age >= 18 }}` | -| Additive | `+` `-` | `{{ a + b }}` | -| Multiplicative | `*` `/` `%` | `{{ total % 2 }}` | -| Unary | `!` `+` `-` | `{{ !done }}` | -| Member | `.` `?.` | `{{ user?.profile.name }}` | -| Grouping | `( )` | `{{ (a + b) * c }}` | -| Literals | numbers, strings, `true` `false` `null` `undefined` | `{{ 1 + 2 * 3 }}` | - -## Snippets - -Type a prefix and press Tab to drop in the syntax that is easiest to forget. - -| Prefix | Inserts | -| ---------- | ------------------------------------- | -| `dve` | `{{ value }}` | -| `dveraw` | `{{{ html }}}` | -| `dveinc` | `{{> partials/header.dve }}` | -| `dveif` | `{{#if}} ... {{else}} ... {{/if}}` | -| `dveifn` | multi-line `{{#if}}` block | -| `dveeach` | `{{#each items as item}}` block | -| `dveeachm` | `#each` block with `@index`/`@length` | -| `dvetern` | `{{ cond ? yes : no }}` | -| `dvedef` | `{{ value ?? 'fallback' }}` | -| `dveopt` | `{{ user?.name ?? 'Guest' }}` | -| `dvecmt` | `` | - -## Advanced Examples - -### Layout + Partial Composition - -`views/layout.dve`: - -```txt - - - {{> partials/header.dve}} -
- {{{ bodyHtml }}} -
- {{> partials/footer.dve}} - - -``` - -`views/partials/header.dve`: - -```txt -
-

{{ title ?? 'Untitled' }}

- {{#if user?.name}}

Hello {{ user.name }}.

{{else}}

Hello Guest.

{{/if}} -
-``` - -### Lists With Conditional Blocks - -```txt -{{#if items?.length ?? 0}} - -{{else}} -

No items.

-{{/if}} -``` - -### Nested Each (Matrix-Style) - -```txt - - {{#each rows as row}} - - {{#each row as cell}} - - {{/each}} - - {{/each}} -
{{ cell }}
-``` - -## What DVE Does Not Do - -DVE stays small on purpose so a template can never run arbitrary code, and these limits are the safety boundary rather than missing features: - -- No function or method calls -- No array indexing like `items[0]` -- No assignment or variable declarations -- No regular expressions or arbitrary JavaScript - -Two guardrails also stop runaway templates, where include nesting is capped at 64 levels deep and a single `#each` is capped at 100,000 iterations, and crossing either one raises a clear error instead of hanging. - -Anything that needs real logic belongs in the route handler, where the finished value gets computed and then passed into the template. - -## Editor Scope Mapping - -| Syntax | Scope | -| ---------------------------------- | ------------------------------------------------------- | -| `{{` `}}` `{{{` `}}}` | `meta.tag.output.dve` / `meta.tag.raw.dve` | -| `#if` `#each` `else` `/if` `/each` | `keyword.control.dve` | -| `>` (include) | `keyword.control.include.dve` | -| `as` (in #each) | `keyword.operator.as.dve` | -| Include path | `string.unquoted.path.dve` | -| `true` `false` `null` `undefined` | `constant.language.dve` | -| Numbers (incl. `1e3`) | `constant.numeric.dve` | -| `"..."` `'...'` | `string.quoted.double.dve` / `string.quoted.single.dve` | -| Operators `?.` `===` `??` etc. | `keyword.operator.dve` | -| Identifiers / variables | `variable.other.dve` | -| Item name in #each | `variable.parameter.dve` | diff --git a/editor/dve/dve-language-0.1.0.vsix b/editor/dve/dve-language-0.1.0.vsix deleted file mode 100644 index a729a0dd3114f0f52c7afda1414965d3505c67df..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7364 zcmaKxWmr`0+V>fxq$MSWl5Uai0qGu)7Nn#Gh8RjZht5F->8_zuO1h*Qq(M+X5b1nz z@7H_Z_kQ2!IoF4ETQR3Vz1UC5Mys!IEw zZ_f6B5XD|TCx2!(`TU*+4QV60PaTAfYvG{Fo z35&%;#0%n^x@`?ecA9fKe>k~Mr;j>sY?}#WRyaS>Et1Vkn?6tVdPwukg6X3rMC~>G zXz-mRP2W5q=k=EWuua1(yUkRSvWRyz#VY|AM0Wl;!i<0GrYau?xQ&{X~V{2jx&dP>VgLKNuE@m zb(Xp9{&0xk!6|yt#=nqzIzBh3^Svy&r9K?N#G)bfp(a;;`DGdqALv(_j+wvuQ=jIC zYRBuHyJSa0c-MRtnG|FN!*z}Naw(-Oa{y+vx)RkyyNQVIs4!oMMbqU^lDu4tF4&a8 ziW&(Wlp6l!n&vlfL@3ceCfHX%2HJeQwLTh~R_E;Eek`W!hpPFHl>M~c^rP=SRbc=W z6yp20ku22lK51d5I$lmzt}nSg9U#7A9qL`YxV=BGG8hJ1)z$(C?V7t*@jL+>Zq1c* zv3Agq28AU@n+?d#ZRVz*DO)p6d1?se-IV(>y+R5RPq5VDmzK+$#$73Y?2=N10j57c z2bA7MoFT9t9M7?3po==GJHtd0&Z5sN?y;m2-Wdjoaln&0JrM2^`EXSf=CkOKt2)P8 z2p*ei?uvnw2E)NFk*}1575QuB9pva}e>Ak|oM(2QTn9`XV^ZqkpzYcw9>Bln6cah6 zYVBuKAcQE(HK;w>|Lr%{eW{-1ml)r>`j`EqRC%1tEbPs!t+?%6p^nMm4!e8TTemr$ zt5cOZ;sk?6_mR;Z@;tc@SagBVSRh287IAx3NeoAn!GT;7t!HJ0Dj4xpCKhc$#=4z@ zRhVTE=N(^vqN(v!ERihz;2{}gfb33&&J?LK^gBw(h8-Os6W&CEEmO@_l!#M4iO#bbmp&Qx_~j6Pm~eBNLwr3NOfxUtkC+c(Z^!a6%C~i71CbTm};tE zVtH{;%evktB=P|Y%KscTruzW?)nR!cW{%cw_i^L0fI7amwRUqcgZ*y2zoQr$Uxgpc zi~Ib{C)5o`r7fUNyWz(tY1whl&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('